Compare commits

...

209 Commits

Author SHA1 Message Date
Ben Stull 7bcf784d06 Merge pull request 'fix(admin): absolute sidebar nav links (v0.52.2)' (#44) from fix-admin-nav-relative-links into main 2026-06-09 03:08:25 +00:00
Ben Stull 8e207a60e6 fix(admin): absolute sidebar nav links so /admin URLs don't accumulate (v0.52.2)
The /admin left-rail NavLinks used relative targets (to="users", etc.). Under
the /admin/* nested route a relative link resolves against the current URL, so
each click appended a segment — Users→Allowlist→Graduation yielded
/admin/users/allowlist/graduation instead of /admin/graduation. Use absolute
targets (/admin/<tab>) + `end` for exact active matching.

Patch 0.52.1 → 0.52.2; CHANGELOG updated. Caught by the operator on PPE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:44 -07:00
Ben Stull fbaa975b5c Merge pull request 'fix(§22.4a): scope RFCView entry-detail fetch to its collection (v0.52.1)' (#43) from fix-collection-scoped-entry-detail into main 2026-06-08 13:45:42 +00:00
Ben Stull ba37da927a fix(§22.4a): scope RFCView entry-detail fetch to its collection (v0.52.1)
The §9 deployed-environment E2E harness (0.52.0), run against a PPE host
with per-collection-isolated content, surfaced a latent multi-collection
bug: RFCView computed the collection id from the route but called
getRFC(pid, slug) without it, so a named-collection entry was always
fetched via the project default-collection route — which 404s for an entry
that exists only in a named collection ("Error: Not found"; metadata panel
absent). Local/Tier-1 stacks masked it (same slug also reachable via the
default collection). Thread cid through all three getRFC call sites; re-run
the load effect on collection change.

Harness/test-infra (not in the deployed artifact):
- e2e: pre-record cookie consent via addInitScript (lib/fixtures.js) so the
  bottom-fixed consent banner can't intercept catalog row-select clicks on
  the slower deployed edge.
- testing/seed-ppe.sh: fail loudly on any non-2xx Gitea response (a
  swallowed 403 org-repo create had reached the deploy as a 502).
- testing/ppe-deploy-and-test.sh: seed via the Keychain admin token
  (write:organization needed to create the PPE repos); store the E2E secret
  newline-free; read EXPECT_VERSION from VERSION.

Patch bump 0.52.0 → 0.52.1; CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 06:45:01 -07:00
Ben Stull 9c8035bdbd test(e2e): one-shot PPE deploy+E2E resume script
Runs the whole §9 PPE stage non-interactively after the operator's gcloud
reauth: bot-token-seed the PPE repos -> ensure E2E secret -> deploy ->
wait for health=0.52.0 + bdd-collection sync -> run metadata.spec.js
against the deployed host. Idempotent; secrets fetched from SM, never
echoed. Test/ops infra (not in the deployed artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:01:19 -07:00
Ben Stull fd123da6a3 test(e2e): harden harness for deployed runs (retries, serialize, banner)
Test-infra only (e2e/ is not in the deployed artifact). Refines the
v0.52.0 deployed-env harness:
- retries:2 + on-first-retry trace now meaningful (was dead: retries
  defaulted to 0); de-risks timing flakes over the public edge.
- workers:1 — the metadata specs run in order and write real commits;
  parallel workers would race on shared state and concurrent bot pushes.
- deployed timeouts bumped (60s/20s) when E2E_TEST_AUTH_SECRET is set.
- dismissCookies forcibly removes any lingering consent-banner node so it
  can't intercept catalog-footer checkbox clicks (the recurring flake).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:56:32 -07:00
Ben Stull 96e2214213 Merge pull request 'v0.52.0 — deployed-environment E2E harness + gated test-auth' (#42) from ppe-e2e-deployed-harness into main 2026-06-08 06:36:18 +00:00
Ben Stull 83eafe72ee feat(e2e): deployed-environment E2E harness + gated test-auth (v0.52.0)
The §9 pipeline's PPE+E2E stage was unreachable: e2e/metadata.spec.js was
bound to Tier-1-only scaffolding (docker-seeded faceted collection,
SQLite-injected owner, Mailpit OTC sink). This makes the same suite run
against a deployed host.

- backend: POST /auth/test/login — fail-closed, secret-gated, single-
  identity owner test-login (404 unless E2E_TEST_AUTH_SECRET +
  E2E_TEST_AUTH_EMAIL both set; constant-time compare; loud startup warn).
  6 vertical tests; backend 665 green. Documented in backend/.env.example.
- e2e/lib/auth.js: branch on E2E_TEST_AUTH_SECRET (deployed test-login vs
  local Mailpit OTC); OWNER_EMAIL from E2E_OWNER_EMAIL. Spec unchanged so
  the localhost Tier-1 path keeps working.
- testing/seed-ppe.sh: seed a dedicated, prod-untouching PPE registry +
  content repo (faceted bdd collection) — real OHM content never touched.
- docs/design/2026-06-07-deployed-env-e2e-harness.md; CHANGELOG; VERSION
  + frontend/package.json -> 0.52.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:53 -07:00
Ben Stull dd9ceff69e Merge pull request 'v0.51.1 — collection-id divergence fix + faceted-catalog scoping fix + metadata E2E' (#41) from fix-mig029-collection-divergence into main 2026-06-08 05:30:47 +00:00
Ben Stull 52f465b4dd release: v0.51.1 — collection-id divergence + faceted-catalog scoping fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:30:09 -07:00
Ben Stull 2fc7029bd9 test(e2e): §22-current Tier-1 harness + metadata E2E (SLICE-3/4/5)
Modernize the Tier-1 stack to the three-tier app and add browser coverage for
the §22.4a metadata UI, closing the E2E gap deferred across SLICE-3/4/5:
- seed-gitea.sh: create a REGISTRY_REPO with projects.yaml + a faceted named
  collection (.collection.yaml fields: priority enum + tags) seeded with three
  metadata-bearing entries; register content+registry webhooks; self-guarding
  (skip if a prior token still works) so a dependency-triggered re-run can't
  remint and invalidate the backend's token.
- .env.tier1: REGISTRY_REPO/DEFAULT_PROJECT_ID; disable OTC cooldown + lift the
  per-IP auth limiter for the single-IP test runner.
- docker-compose: pin backend image; backend-seed inserts a granted owner the
  OTC path can sign in as (write paths need contributor+).
- Makefile: two-phase tier1-up (seed to completion, then create backend so it
  reads the populated token env); robust down; e2e-fresh = down+up+e2e (the
  canonical run, since the edit/bulk specs mutate the seeded corpus).
- metadata.spec.js: SLICE-3 faceted filter (anon), SLICE-4 edit panel (owner),
  SLICE-5 bulk bar (owner). 4 passed against a fresh stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:29:11 -07:00
Ben Stull 9e1b7ce34f feat(ratelimit): env-tunable per-IP auth limiter budgets
Add RATELIMIT_OTC_REQUEST_MAX / RATELIMIT_VERIFY_MAX / RATELIMIT_CHECK_MAX
(default to the existing secure values; non-positive/unparseable → default).
Lets a test/PPE stack that drives the auth endpoints repeatedly from one IP
lift the budget; production leaves them unset. Mirrors the existing
OTC_REQUEST_COOLDOWN_SECONDS knob.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:28:57 -07:00
Ben Stull cbaba76345 fix(§22): catalog scopes to the named collection in the URL (E2E-caught)
useCollectionId read useParams().collectionId, but the Catalog renders at
/p/:projectId/* — outside the c/:collectionId route — so it always fell back
to the default collection. The faceted filter (SLICE-3) and bulk action bar
(SLICE-5) therefore never scoped to a named (fields-bearing) collection in the
deployed app; the Catalog unit test had mocked useCollectionId, hiding it.
Resolve the /c/<id>/ segment from the pathname when the route param isn't in
scope. Caught by the new Playwright metadata suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:28:57 -07:00
Ben Stull 620926b834 fix(§22): heal mig-029 vs registry-mirror collection-id divergence
On a deployment that already had >=2 projects when migration 029 ran, 029
seeds the default project's collection id as the project id (e.g. 'ohm'),
but the registry mirror expects 'default' -> it inserted a duplicate empty
'default' collection, orphaning the entries.

Adds projects.reconcile_default_collection_id() -- the collection-grain twin
of restamp_default_project -- run at startup BEFORE the mirror so it merges
onto the canonical 'default' collection instead of duplicating. Renames the
divergent collection id and cascades collection_id across all keyed tables
(FK-off atomic rename + foreign_key_check). Idempotent; no-op on fresh /
single-project / already-aligned deploys. Seam tests show the duplicate forms
without the fix and merges with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:54:52 -07:00
Ben Stull 4ac3955056 Merge pull request 'v0.51.0 — SLICE-5: bulk tag/untag metadata (§22.4a PUC-2)' (#35) from slice5-bulk-metadata into main 2026-06-08 04:04:21 +00:00
Ben Stull 7886840362 fix(slice5): validate raw metadata input + prune stale bulk selections
Code-review follow-ups:
- Validate the raw submitted value before apply_values coerces it, in both
  the single-edit and bulk endpoints — a scalar set onto a tags field now
  rejects (422 / rejected) instead of silently char-splitting into a list.
- Catalog prunes bulk selections to the currently-visible (filtered) entries
  after each list fetch, so the bulk bar can't act on rows the user has
  filtered out of view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:03:29 -07:00
Ben Stull 8eee907893 docs(slice5): record SLICE-5 bulk edit shipped in SPEC §22.4a status
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:52:48 -07:00
Ben Stull a2b55f94ce release(slice5): v0.51.0 — bulk tag/untag metadata (§22.4a PUC-2 SLICE-5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:52:16 -07:00
Ben Stull edbf68909a feat(slice5): catalog multi-select + bulk action bar (§22.4a PUC-2)
Faceted catalog rows are selectable for contributors; a sticky bulk bar
(BulkActionBar) drives set/add/remove gestures from the collection fields
schema, calls bulkEntryMeta (one commit), toasts applied/skipped counts,
and re-fetches. Non-contributors and legacy (no-fields) collections see
no selection (INV-5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:51:48 -07:00
Ben Stull 282706d7ef feat(slice5): POST .../meta/bulk one-commit bulk metadata edit (§22.4a PUC-2)
set/add/remove ops reusing the SLICE-4 sidecar write-through; per-entry
partial-rejection; contributor+ gated (INV-4); validated at the write
boundary. Tests cover one-commit, add/remove, partial reject, authz,
and op/field/empty guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:48:54 -07:00
Ben Stull eaf69cd05c Merge pull request 'v0.50.0 — SLICE-4: single-entry metadata edit (§22.4a)' (#34) from worktree-metadata-slice4 into main 2026-06-08 02:18:21 +00:00
Ben Stull 0d2fdfacf2 release(slice4): v0.50.0 — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:17:25 -07:00
Ben Stull abd17a6cc8 feat(slice4): schema-driven metadata edit panel in RFCView (§22.4a PUC-1 §5.2)
MetadataFieldsPanel renders one control per declared field (enum→select,
tags→chips, text→input), read-only without contribute access, saving changed
values via saveEntryMeta (direct sidecar commit). Wired into the canonical
main view, gated on the collection declaring fields (INV-5). saveEntryMeta
API client added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:14:13 -07:00
Ben Stull 46c957cff5 feat(slice4): Owner-gated collection migrate endpoint (§22.4a PUC-5)
POST .../collections/{cid}/migrate — now safe to ship since all write paths
are sidecar-aware. Idempotent, one commit per collection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:01:18 -07:00
Ben Stull d687a65470 feat(slice4): POST .../meta single-entry edit endpoint + GET RFC meta/can_edit_meta (§22.4a PUC-1)
Direct-commit to the sidecar (D7), contributor+ gated (INV-4), schema-validated
at the write boundary, lazy-migrates a legacy entry, re-ingests. GET RFC now
returns the per-entry meta mapping and a can_edit_meta capability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:00:40 -07:00
Ben Stull aee9b582e5 fix(slice4): make all entry write paths sidecar-aware (§22.4a carried from SLICE-1)
graduate, claim, retire/unretire, _read_meta_entry, mark_entry_reviewed,
body extract/wrap (api_branches + api_prs replay) now dual-read and write
metadata to the sidecar via write_entry_files + bot.commit_entry_files/
open_entry_pr — a migrated body-only .md no longer crashes entry.parse or
re-grows frontmatter; legacy entries lazy-migrate on first metadata write.
Existing tests updated to assert the sidecar (INV-2 clean docs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:57:54 -07:00
Ben Stull 734290f344 feat(slice4): bot.commit_entry_files + open_entry_pr multi-file primitives (§22.4a)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:46:09 -07:00
Ben Stull 49981e2d6e feat(slice4): metadata sidecar git read/write helpers — dual-read + lazy-migrate ops (§22.4a)
apply_values, EntryGitState, read_entry_from_git, write_entry_files, sidecar_path_for.
Plus the SLICE-4 implementation plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:44:14 -07:00
Ben Stull 7ece6d348b Merge pull request 'v0.49.0 — SLICE-3: faceted left-pane filtering (§22.4a)' (#33) from worktree-metadata-slice3-facets into main 2026-06-08 00:44:54 +00:00
Ben Stull 6bb6d654fa release(slice3): v0.49.0 — faceted left-pane filtering (§22.4a SLICE-3) 2026-06-07 17:43:56 -07:00
Ben Stull 276a625997 test(slice3): Catalog faceted-pane component test (§22.4a PUC-3) 2026-06-07 17:41:04 -07:00
Ben Stull ae3afe2f48 feat(slice3): faceted catalog pane gated on collection fields (§22.4a §5.1) 2026-06-07 17:40:29 -07:00
Ben Stull ae083bfcaa feat(slice3): FacetGroups faceted-pane component (§22.4a §5.1) 2026-06-07 17:38:35 -07:00
Ben Stull bcce40d2cb feat(slice3): listRFCs accepts facet selections, returns {items,facets} (§22.4a) 2026-06-07 17:38:16 -07:00
Ben Stull 7b269e11c4 test(slice3): integration tests for faceted list endpoint (§22.4a PUC-3) 2026-06-07 17:37:46 -07:00
Ben Stull 27061c30b0 feat(slice3): collection list endpoint returns facets + honours filters (§22.4a) 2026-06-07 17:35:26 -07:00
Ben Stull 14ea3c0cce feat(slice3): pure facet field-set + filter/count helper (§22.4a) 2026-06-07 17:34:33 -07:00
Ben Stull 644bf35d89 feat(slice3): persist entry metadata to cached_rfcs.meta_json at ingest (§22.4a) 2026-06-07 17:33:47 -07:00
Ben Stull 3636fa5afd feat(slice3): migration 034 — cached_rfcs.meta_json for facet values (§22.4a) 2026-06-07 17:33:11 -07:00
Ben Stull 1bcf8aa77e Merge pull request 'v0.48.0 — SLICE-2: collection field schema + central validation (§22.4a)' (#32) from worktree-metadata-slice2-schema into main 2026-06-08 00:06:41 +00:00
Ben Stull e336e31812 v0.48.0 — SLICE-2: collection field schema + central validation (§22.4a)
Collections declare a `fields:` schema in `.collection.yaml`; entries carry
typed metadata (enum/tags/text). New central `metadata_schema` module parses
the schema leniently (INV-3) and validates entry values — advisory at read
(corpus mirror flags violations as `metadata_malformed` without hard-failing),
the enforcement point for the write boundary (edit endpoints land SLICE-4/5).

- app/metadata_schema.py: parse_fields (lenient/normalizing) + validate
- registry.parse_collection_manifest reads `fields:` into collection config
- collections.get_collection unpacks `fields`; served by the collection API
- cache._refresh_collection_corpus validates each entry advisory-only

Non-breaking, opt-in: no `fields:` → unchanged (INV-5); default `document`
collection declares none (N=1 unchanged). No DB migration — schema rides in
collections.config_json. SLICE-2 of
docs/design/2026-06-06-configurable-collection-metadata.md §7.2.

Backend suite green (601 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:06:04 -07:00
Ben Stull 98c276a662 Merge pull request 'v0.47.0 — SLICE-1: metadata sidecars (storage + dual-read + migration tool)' (#31) from worktree-metadata-slice1-sidecar into main 2026-06-07 15:24:55 +00:00
Ben Stull f05ee59763 v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md
(§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with
the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no
sidecars present every corpus stays on the legacy frontmatter path,
byte-identical (N=1 unchanged).

- Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter,
  identical records (INV-6); unknown/forward-compat keys ride along through
  parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar
  (malformed/empty/slug-less) never drops the entry — slug backstopped from the
  filename stem, flagged not lost (INV-3).
- Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles
  commit per collection (new `gitea.change_files`). Tested as a function; its
  Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become
  sidecar-aware first — see the design's SLICE-4 note + INV-8). No production
  trigger ships here, so no corpus is rewritten.
- Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed`
  (additive); the corpus mirror derives it; catalog list + entry-detail APIs
  surface `metadata_malformed`.
- INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild
  instead of dropping forward-compat keys.

Gate: backend 575 passed (28 new: test_metadata / _migration / _cache +
graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 +
upgrade-steps; VERSION + frontend/package.json -> 0.47.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:24:27 -07:00
Ben Stull b1acc2382d Merge pull request 'v0.46.2 — SLICE-0: §22.4a contract amendment (entry metadata is collection-configured, not type-driven)' (#30) from worktree-metadata-slice0-spec into main 2026-06-07 14:33:21 +00:00
Ben Stull 5cb5f4a4a2 v0.46.2 — SLICE-0: §22.4a contract amendment (entry metadata is collection-configured, not type-driven)
Reframes binding SPEC.md §22.4a so a collection's entry metadata schema is
collection-configured — a `fields:` schema in `.collection.yaml` plus per-entry
`<slug>.meta.yaml` sidecars — rather than a frontmatter schema hard-wired to the
collection's `type`. Item 3's type-specific surfaces (release planning;
bdd scenario/coverage views) are deferred to a future design; bdd coverage is
recorded as a future `ref`-field surface rendered as hyperlinks (no cross-
collection corpus fusion). `type` still selects terminology (entry noun,
v0.45.0) + default initial_state/review posture (§22.4b-c).

SLICE-0 of docs/design/2026-06-06-configurable-collection-metadata.md (§7.2);
supersedes the per-type-surfaces draft (D11). Doc-only: per-type frontmatter
validation was never implemented, so no operator action, no schema/behavior
change. Sidecar storage + validation + UI arrive in SLICE-1+.

- SPEC.md §22.4a reframed; document/specification/bdd bullets updated; metadata
  amendment blockquote added.
- SPEC.md §2 and §22 forward-pointer blockquotes: "type-dependent frontmatter
  schema" -> "collection-configured, not type-driven (§22.4a, as amended)".
- CHANGELOG 0.46.2; VERSION + frontend/package.json -> 0.46.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:32:27 -07:00
Ben Stull 36cb6187eb Merge pull request 'preview.env.example: flotilla-core overlay set (retire shim ref)' (#29) from fix/preview-env-flotilla-core into main 2026-06-07 05:52:03 +00:00
Ben Stull 677c5eb72f preview.env.example: flotilla-core overlay set (not the retired per-app shim)
The example comment referenced 'ohm-rfc-app-flotilla overlay set' — the retired
per-app shim. flotilla-core is the operator CLI for all deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:44:09 -07:00
Ben Stull 077563ea47 Merge pull request 'docs(spec): Corpus Tree — universal directory-tree left pane (Solution Design)' (#28) from docs/corpus-tree-spec into main 2026-06-07 00:55:02 +00:00
Ben Stull dc5345cef4 docs(spec): Corpus Tree — universal directory-tree left pane (Solution Design)
Solution Design for making the left pane a universal git-directory tree:
host existing documentation repos as path-addressed corpora, full
governance lifecycle per file, dual-mode (structure/flat) pane replacing
the §7 Catalog, zero-migration onboarding ("no record = active").

Discovery output of ohm session 0081.0. Conforms to handbook §3.3
Solution Design standard. Sliced SLICE-1..4 in §7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:47:24 -07:00
Ben Stull ee74a39b62 Merge pull request 'v0.46.1 — fix migration 029 for the §22.13 re-stamp aftermath (OHM deploy fault)' (#27) from fix/migration-029-orphan-collection into main 2026-06-06 19:03:20 +00:00
Ben Stull 2f507e5721 v0.46.1 — fix migration 029 for the §22.13 re-stamp aftermath (OHM deploy fault)
Deploying the three-tier series onto OHM crash-looped on migration 029:
`NOT NULL constraint failed: cached_branches__new.collection_id`, then a UNIQUE
collision. Root cause: the v0.39.0 default→ohm re-stamp updated
cached_rfcs.project_id but NOT the entry-satellite tables, so ~1.3k
cached_branches rows were stranded at project_id='default' — which 029's
per-project collection backfill can't map (NULL), some of which also duplicate
freshly-re-mirrored 'ohm' rows (UNIQUE), and some of which reference entries
that no longer exist.

Fix — a repair prologue at the top of 029 (no schema change):
- drop stale rows that duplicate an already-correctly-stamped row (keep the
  fresh copy) for the branch-keyed tables;
- re-derive each satellite's project_id from its entry (cached_rfcs, by slug);
- drop rows whose entry no longer exists (stale cache, rebuildable from gitea).
A no-op on clean/fresh deployments (empty or consistent satellites).

Validated against a snapshot of the live OHM DB: 029→032 apply cleanly, zero
NULL collection_id, FK check clean, watches/RFCs/branch_visibility preserved,
cached_branches 1397 → 1291 (−67 no-RFC, −39 dups). Fresh-install path
unchanged: existing 029 suite green + a new regression test for the
stale/dup/orphan shape. Full backend suite 547 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:02:53 -07:00
Ben Stull 9785782532 Merge pull request 'Discovery spec: configurable collection metadata (clean-doc tagging)' (#26) from docs/collection-metadata-design into main 2026-06-06 18:43:26 +00:00
Ben Stull 43a002c6aa Spec v0.1.6: §7.1 execution convention (one slice, one session, just-in-time)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:19:28 -07:00
Ben Stull 8ce3e5792d Spec v0.1.5: define Business Actors (§1.3) before Problem/Pain reference them
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:12:20 -07:00
Ben Stull 1be4a2edbf Spec v0.1.4: two-part structure — Business Context (§1) + Solution Proposal (§2)
- §1 Business Context holds the whole business lens (1.1–1.9), solution-agnostic
- §2 Solution Proposal introduces the chosen approach (and justifies build over
  a manual alternative); may be non-software in general
- Renumber product/engineering lenses to §§3–7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:02:33 -07:00
Ben Stull 561cd73760 Spec v0.1.3: supersede per-type-surfaces; split personas; harvest patterns
- Supersede docs/design/2026-06-06-per-type-surfaces.md (banner added there);
  harvest validation seam, malformed-metadata flag, unknown-fields-ride-along,
  engine-unchanged invariant, N=1 document backcompat
- bdd coverage kept as a future per-type surface over a generic `ref` field
- Schema model = pure collection-config (not type-driven)
- SLICE-0 added: amend binding SPEC.md §22.4a (frontmatter→sidecar,
  type-driven→collection-configured, defer surfaces)
- Split personas: §6 Business Actors/Roles (solution-agnostic) + §10 Product
  Personas (mapped to business roles); renumber accordingly

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:34:15 -07:00
Ben Stull 3c910e89ab Revise spec to template: value-only summary, Pain Points, business framing
- Executive Summary → value-only (no solution)
- New §4 Pain Points (PP-1..PP-7)
- Business Outcomes → §5, restated as business outcomes (adoption/diversity),
  not solution outputs
- Business Use Cases rewritten as solution-agnostic actor-goal scenarios
- Renumber per the Solution Design template revision

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:15:35 -07:00
Ben Stull b7e23a01f8 Merge pull request '§22 S6 (last item): spec pass for per-type surfaces (§22.4a items 1 & 3)' (#25) from spec/s7-per-type-surfaces into main 2026-06-06 09:16:07 +00:00
Ben Stull 281dd29e62 §22 S6 (last item): spec pass for per-type surfaces (§22.4a items 1 & 3)
The per-type frontmatter schemas (item 1) and type-specific surfaces (item 3:
specification release-planning; bdd scenario/coverage views) were flagged at
v0.45.0 as wanting a discovery/spec pass first (they lacked BDD scenarios in
Part C). This is that pass — a design doc specifying the schemas, the surfaces,
their data model / API / frontend shape, BDD-style acceptance scenarios, and a
three-slice delivery plan (S7a schemas, S7b releases, S7c bdd surfaces), all
additive and engine-preserving per §22.4a. Doc-only; no code, no version bump.

- docs/design/2026-06-06-per-type-surfaces.md — the new spec pass.
- three-tier design doc S6 bullet: forward-pointer + S6 shipped/spec'd status.

Leaves implementation to the S7a–S7c coding slices; S7a (schemas) is unblocked,
S7b/S7c carry open product questions for the operator/discovery to settle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:15:45 -07:00
Ben Stull 1c17fecea3 Merge pull request '§22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0' (#24) from feat/s6-join-requests into main 2026-06-06 09:12:00 +00:00
Ben Stull fcc3c84d76 §22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.

Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
  requester, requested_role, message, status, granted_role); one-open-per
  (scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
  {id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
  Accept grants via memberships.grant; the request POST does not require the
  scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
  _scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
  folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.

Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
  collection directory + catalog footer; JoinRequestRow in the inbox.

Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.

Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:11:16 -07:00
Ben Stull e86fc65643 Discovery spec: configurable collection metadata (clean-doc tagging)
Solution Design for a generic per-collection metadata system — typed fields
(enum/tags/text) declared in .collection.yaml, per-entry values in a clean
<slug>.meta.yaml sidecar, schema-derived faceted left-pane filtering, and
single/bulk direct-commit tag/untag. Restores the retired BDD Release Planner's
corpus-annotation half inside the framework; release planning stays downstream.

Output of discovery session OHM-0079.0. Proposed as §23; flag for reconciliation
with §22 S6 type-modules at the SPEC merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:52:26 -07:00
Ben Stull 014015014b Merge pull request '§22 S6: SPEC merge + per-collection model universe + type noun — v0.45.0' (#23) from feat/s6-three-tier-final into main 2026-06-06 08:44:25 +00:00
Ben Stull b392fa923c §22 S6: release v0.45.0 — two-project/multi-collection test + §20.4 changelog
The release wrap for the S6 scope shipped this slice (SPEC merge, per-collection
enabled_models, type-driven entry noun).

- test_s6_two_project_multicollection.py: an integrative pass proving the new
  per-collection knobs (§22.12 enabled_models, §22.4a noun) resolve independently
  per collection across two projects with no cross-project/-collection bleed.
- CHANGELOG.md 0.45.0: the §20.4 entry + upgrade-steps (migration 031 is
  additive + automatic; per-collection enabled_models and typed collections are
  optional). Notes the two OPEN S6 items carried to a follow-up slice (per-type
  surfaces; request-to-join + cross-collection inbox).
- VERSION + frontend/package.json → 0.45.0.

Gate: backend 530 passed, frontend 30 passed, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:43:09 -07:00
Ben Stull 839404da0c §22 S6: type-driven entry noun (§22.4a) — backend source of truth + chrome
The displayed noun for an entry is a framework concept keyed on the collection's
immutable type — document→"RFC", specification→"Spec", bdd→"Feature" — not
deployment content. The chrome reads it from the API instead of hardcoding "RFC".

- collections.ENTRY_NOUN + entry_noun(type) (unknown type → generic "RFC").
- Surfaced as entry_noun on get_collection, list_collections items, the
  /api/deployment directory items, and GET /api/projects/:id.
- Frontend: Catalog reads entry_noun for the "+ Propose New <noun>" control;
  ProposeModal fetches the active collection's noun for its title + field copy.
- test_s6_entry_noun_vertical.py: map + API surfacing (collection + directory).

Scope note: this is §22.4a item (2) — terminology. The deeper type-module work
(item 1 per-type frontmatter schemas; item 3 specification release-planning +
bdd scenario/coverage surfaces) is flagged OPEN in the design doc and is handed
off to a follow-up spec+slice.

Backend 528 passed; frontend 30 passed + build green. Part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:35:57 -07:00
Ben Stull 79a27a946b §22 S6: per-collection enabled_models — the §22.12 model-universe chain
A collection's .collection.yaml may carry an enabled_models list that NARROWS
its project's universe, which narrows the deployment ENABLED_MODELS. Resolution
(extending §6.6/§6.7): funder ∩ per-entry models ∩ collection ∩ project, with
the operator providers as the ceiling.

- migration 031: additive collections.config_json (parallels projects.config_json).
- registry.parse_collection_manifest: read enabled_models into CollectionEntry.config
  (absent = inherit, present incl. [] = narrow; [] opts the collection out of AI);
  reject a non-list. _upsert_named_collection persists config_json. The default
  collection (from projects.yaml) leaves it NULL — it inherits the project.
- models_resolver: _scope_narrowed_universe narrows the operator universe by the
  entry's project then collection enabled_models; the funder universe is bounded
  by it too. A collection cannot widen its project (narrowing from the ceiling).
- collections.get_collection surfaces enabled_models; GET
  /api/projects/:id/collections/:cid returns it.
- test_s6_collection_models_vertical.py: 9 cases (parse, the narrowing chain
  incl. opt-out + cannot-widen, API surfacing).

Backend: 525 passed. Per SPEC §22.12. Releases as part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:30:16 -07:00
Ben Stull 26f3680197 §22 S6: merge three-tier model into SPEC.md + registry format in DEPLOYMENTS
Write the canonical §22 (deployment → project → RFC collection) into the
binding spec, applying Part A of the design doc and the Part D amendments in
place. Lands the S3 keystone reinterpretation of §B.1/§B.3: a plain granted
account is a granted account, not a global write role; "global RFC Contributor"
is an explicit memberships(scope_type='global') grant; the implicit-public
write baseline is grandfathered onto the migration-seeded default collection
only (N=1 preserved).

- SPEC.md §22.1–§22.14: tiers + isolation, the registry + .collection.yaml
  manifests, content-repo-per-project, per-collection slug identity, collection
  type/initial_state/unreviewed, two-tier visibility (narrow-only), the unified
  {owner, contributor} role vocabulary at {global, project, collection}, the
  four-layer most-permissive union, discovery/joining (invite + request-to-join),
  runtime branding, /p/<project>/c/<collection>/ routing, one inbox,
  per-collection model universe, the default-project+collection migration, and
  §22.14 consolidating the §§1–21 amendments.
- Forward-pointer amendment notes (house style) at §1, §2, §5, §6 routing
  readers to §22.
- docs/DEPLOYMENTS.md: the registry (REGISTRY_REPO / projects.yaml) and
  .collection.yaml manifest formats, the N=1 default-project+collection upgrade,
  and in-app create-project/collection.

Per docs/design/2026-06-05-three-tier-projects-collections.md Parts A/B/D/E (S6).
Docs-only; no code or migration. Releases as part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:21:39 -07:00
Ben Stull b0737380cd §22 S5: in-app create-project + global directory — v0.44.0 (@S5) (#22) 2026-06-06 08:03:12 +00:00
Ben Stull 33212c71e4 §22 S5: in-app create-project + global-directory empty states (@S5 C3.1–C3.2)
Adds the global-Owner create-project action and the role-aware deployment
directory, completing slice S5 of the three-tier refactor (release v0.44.0,
minor/non-breaking — no migration). Per
docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5),
Part C.3 (C3.1–C3.2).

Backend:
- auth.can_create_project — global-Owner gate (deployment owner/admin or an
  explicit scope_type='global' Owner grant).
- bot.create_project — provision the Gitea content repo (seed README so main
  exists), read+append+commit projects.yaml in the registry repo, audit-log
  (create_project). The bot stays the only git writer (§1).
- POST /api/projects (api_deployment) — global-Owner gated; validates the id
  (slug, not 'default'), name, type, visibility, content_repo; commits via the
  bot, then re-mirrors the registry so projects + default collection rows flow
  from git (§22.2). GET /api/deployment gains viewer.can_create_project and
  default_project_readable. make_router now takes gitea + bot.

Frontend:
- api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable
  + refresh; DeploymentLanding redirects into the default only when readable
  (gated/absent default falls through to the directory, no 404 bounce).
- Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA;
  C3.2 "Nothing has been shared with you yet") + Owner-only "New project"
  control + CreateProjectModal.

Tests: backend test_create_project_vertical.py (vertical + gates + the
deployment empty-state signals); frontend Directory.test.jsx empty-state cases.

Also: ignore the session-local .superpowers/ tooling dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:02:16 -07:00
Ben Stull 2696e64ff5 Merge pull request '§22 S4: invitation surfaces + role-aware empty states — v0.43.0 (@S4)' (#21) from feat/s4-invitation-surfaces-empty-states into main 2026-06-06 07:31:01 +00:00
Ben Stull ff54632657 §22 S4: release v0.43.0 — invitation surfaces + role-aware empty states (@S4)
Bump VERSION + frontend/package.json to 0.43.0, add the CHANGELOG entry
(minor, non-breaking — additive endpoints + UI, no migration, no change to
existing authz outcomes), and mark Part E slice S4 shipped in the design doc.

Completes @S4 (C2.1–C2.7 invitation + C3.3–C3.5 role-aware empty states).
Next: S5 (in-app create-project + the global directory, C3.1–C3.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:30:12 -07:00
Ben Stull 93cf506059 §22 S4: invitation modal + role-aware empty states (@S4 C.2, C.3)
The frontend surfaces for the S4 backend (Part E S4 / Part C.2, C.3):

- ScopeMembersModal — the Owner-only scope-role invitation surface (sibling of
  the per-RFC InvitationsModal): grant {owner, contributor} by email at the
  project or a single collection, with a scope picker bounded to the inviter's
  reach (no parent-grant-child-exclude option, C.2.5), plus a current-members
  list with revoke. Opened from the directory's owner-only "Members" control;
  contributors never see it (C.2.4).
- CreateCollectionModal — surfaces the S2 create-collection endpoint (was
  UI-less), gated on the viewer's can_create_collection capability.
- CollectionDirectory — reads the new `viewer` capability block to render the
  role-aware empty states: a project Owner sees "Create your first collection"
  (C3.3); a contributor without create rights sees the bare empty directory
  (C3.4); an Owner with management reach sees the "Members" control.
- Catalog — the empty collection shows "Propose the first entry" to a viewer
  who may contribute here (C3.5), and the propose control is gated on the
  collection's can_contribute flag (anon keeps the sign-in prompt, S2).
- api.js — getCollection, listScopeMembers, grantScopeMember, revokeScopeMember.

Frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:27:49 -07:00
Ben Stull c9fd1c535e §22 S4: scope-role invitation backend + capability flags (@S4 C.2)
The Owner-only scope-role grant surface (Part E S4 / Part C.2). An Owner
grants {owner, contributor} at a scope their reach covers — the project or
one collection within it — to an existing account looked up by email; the
grant writes a `memberships` row immediately and §15-notifies the grantee
(direct grant, no accept round-trip — the C.2 scenarios name existing
accounts and write the row directly).

- auth.can_invite_at_project / can_invite_at_collection — the Owner-reach
  invite gates (is_project_superuser / is_collection_superuser).
- memberships.py — grant (with the C.2.6 broader-supersedes-narrower prune,
  preserving a stronger child grant — no negative override), revoke, list,
  user_by_email.
- api_memberships.py — GET/POST/DELETE /api/projects/:id/members, the single
  POST keying on optional collection_id so the invite UI's one control maps
  to one endpoint; reach bounded by the inviter's Owner reach (C.2.3);
  contributors refused (C.2.4); a pending grantee's row is recorded but
  confers no write (C.2.7, the §6 floor).
- notify.notify_scope_role_granted + render_summary — the §15 personal-direct
  notification naming the project and role.
- api_collections — surface viewer capabilities (can_create_collection,
  can_invite, can_contribute, role) on the project/collection GETs to drive
  the C.3 role-aware empty states.

Acceptance: test_s4_invitations_vertical.py covers C.2.1–C.2.7 over the HTTP
surface + resolver, plus the C.3 (@S4) capability flags. Full suite green
(504 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:47:28 -07:00
benstull e6bd69f132 Merge pull request '§22 S3: scope-role enforcement + collection-grain visibility — v0.42.0 (@S3)' (#1) from feat/s3-scope-roles-collection-visibility into main 2026-06-06 01:08:54 +00:00
Ben Stull c2f566512a §22 S3: scope-role enforcement + collection-grain visibility (@S3) — v0.42.0
Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.

- migration 030: memberships.scope_type += 'global' (the global RFC
  Contributor tier; sentinel scope_id '*').
- auth.effective_scope_role folds global → project → collection,
  most-permissive, no negative override; can_read_collection /
  can_contribute_in_collection / is_collection_superuser /
  can_create_collection gate reads, writes, admin, and create.
- collection-grain visibility: a gated collection is hidden from the
  public (404, omitted from the directory) yet visible+listed for a
  scope-role holder; a collection may be set only as strict or stricter
  than its project (public < unlisted < gated), validated at create and
  clamped at the mirror.
- entry-scoped authority (mark-reviewed, graduate, branch read/contribute,
  PR/discussion/contribution moderation) re-pointed from the project grain
  to the entry's collection.
- create-collection authority widened to a project/global-scope grant
  holder (§B.1), not only a deployment owner/admin.

Keystone reconciliation (session 0076): a plain granted account is a
granted *account*, not a write-everywhere global role; the implicit-public
write baseline is grandfathered onto the migration-seeded `default`
collection only, so the N=1 deployment loses no capability. Reinterprets
§B.1/§B.3 literally — flagged for the SPEC merge (S6).

Completes @S3 (C1.1–C1.8). Tests: test_s3_scope_roles_vertical.py (8 C.1
scenarios + visibility/strictness), test_migration_030_global_scope.py.
Full backend suite 493 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:07:51 -07:00
Ben Stull 39ce54fbcc Merge pull request '§22 S2: create & navigate a second collection — v0.41.0 (@S2)' (#20) from feat/s2-second-collection into main 2026-06-05 20:19:25 +00:00
Ben Stull 55d04ce4ca §22 S2: release v0.41.0 — create & navigate a second collection (@S2)
Minor, non-breaking: named collections via .collection.yaml, create-collection
endpoint, collection-scoped serve/propose, and the /p/<project>/ collection
directory. Completes acceptance @S2 (C3.6). 478 backend + 26 frontend green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:17:31 -07:00
Ben Stull bd6dc6524a §22 S2: @S2 acceptance — anonymous empty public collection catalog (C3.6)
Anonymous reader of an empty public collection gets a 200 empty catalog and no
propose action (the propose route rejects anonymous); the Catalog footer's
'Sign in to propose' prompt is the existing anonymous affordance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:14:52 -07:00
Ben Stull 17bdd5fd9a §22 S2: collection directory at /p/<project>/ (1 → redirect, 2+ → list)
Replace DefaultCollectionRedirect with a CollectionDirectory that lists the
project's visible collections, or redirects into the sole one when there is
exactly one (preserving the S1 C3.7/C3.8 single-collection UX). The
create-first-collection empty state is S4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:14:18 -07:00
Ben Stull 2b32e124ab §22 S2: Catalog + propose scoped to the active collection
Catalog reads the /c/:collectionId/ segment via useCollectionId and fetches the
collection-scoped catalog, building entry/proposal links with the active
collection; the propose modal threads the active collection so a propose from a
named collection targets it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:12:45 -07:00
Ben Stull 98eea3e2d6 §22 S2: collection-scoped frontend path + API helpers
useCollectionId() hook; listRFCs/getRFC/proposeRFC take an optional collection
id and target the /collections/<cid>/ routes; add listCollections +
createCollection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:11:27 -07:00
Ben Stull 0c654b173d §22 S2: create-collection endpoint (bot commit + registry refresh)
POST /api/projects/<id>/collections, owner/admin-gated, commits a
.collection.yaml to the content repo main via bot.create_collection, then
re-mirrors the registry so the collections row appears (§22.2). Adds GET
list/one collection routes. Extends FakeGitea to model directory listings so
the mirror's content-repo walk is exercised end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:10:09 -07:00
Ben Stull f57d4080dc §22 S2: collection-scoped list/get/propose endpoints
Refactor the project-scoped serve/propose internals into collection-grained
helpers (_list_rfcs_for_collection, _get_rfc_for_collection,
_propose_into_collection); add routes under
/api/projects/<id>/collections/<cid>/rfcs[/<slug>|/propose]. Propose writes
the entry under the target collection's <subfolder>/rfcs via a new rfcs_dir
param on bot.open_idea_pr. Default-collection routes preserved as wrappers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:05:58 -07:00
Ben Stull 91b0fb358c §22 S2: corpus mirror reads each collection's <subfolder>/rfcs/
refresh_meta_repo now iterates a project's collections and keys cached_rfcs by
collection_id; the default collection (subfolder '') keeps the shipped rfcs/
root path. N=1 default path unchanged (469 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:02:59 -07:00
Ben Stull 868391870c §22 S2: collection read helpers (list/get/subfolder)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:00:28 -07:00
Ben Stull 74476423ba §22 S2: registry mirror reads .collection.yaml manifests
Discover named collections by walking each project's content-repo root for
<subdir>/.collection.yaml; parse + upsert with immutable-type enforcement
(§22.4a) and project-visibility inheritance. The default collection still
flows from projects.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:59:25 -07:00
Ben Stull 599e7018f6 §22 S2: implementation plan — create & navigate a second collection
Plan for slice S2 of the three-tier (deployment→project→collection) refactor.
Completes acceptance @S2 (C3.6). See
docs/design/2026-06-05-three-tier-projects-collections.md Part E.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:55:56 -07:00
Ben Stull 4ffff6b677 Merge pull request '§22 S1: three-tier collection grain — migration 029 + threading + 308 redirect (v0.40.0)' (#19) from feat/s1-collection-grain into main 2026-06-05 15:34:07 +00:00
Ben Stull aaf7b09bbe §22 S1: release v0.40.0 — three-tier collection grain (breaking URL + migration 029)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:31:43 -07:00
Ben Stull 4f72aa31e0 §22 S1: frontend /c/<collection>/ route layer + C3.7 + legacy-URL redirects
- entryPaths builders carry the /c/<collection>/ segment (default collection in S1)
- /p/:projectId/* gains c/:collectionId/ corpus routes; serving stays project-scoped
- DefaultCollectionRedirect (C3.7: project landing -> default collection)
- LegacyCorpusRedirect (v0.35.0 /p/<p>/e/<slug> bookmarks -> /c/default/, query preserved)
- entryPaths unit test; build + vitest green (18 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:30:18 -07:00
Ben Stull 9ca07a3f81 §22 S1: thread collection_id through backend + update tests (N=1 unchanged, 454 green)
- collections.py resolution helpers (default_collection_id, type, initial_state)
- registry mirror writes project grouping fields + default-collection corpus fields
- auth.project_of_rfc joins collections; project_member_role reads memberships
- cache/api_*/funder writers+readers re-keyed to collection_id (cached_prs + denormalised
  project_id tags unchanged); api_deployment reads type/initial_state from the default collection
- projects.py restamp detects bootstrap via collections; initial_state via collection
- tests updated to the three-tier schema; test_migration_028 retired (superseded by 029)
- add @S1 acceptance test (collection grain + N=1 serving)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:26:14 -07:00
Ben Stull 867f2504d6 §22 S1: migration 029 — collections grain, field move-down, 13-table re-key, memberships
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:58:39 -07:00
Ben Stull 08bdea8539 §22 S1 plan: three-tier collection grain (migration 029 + threading + redirect)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:53:41 -07:00
Ben Stull 0de91fe35c Merge pull request '§22 refactor (spec): three tiers — deployment → project → RFC collection' (#18) from spec/three-tier-projects-collections into main 2026-06-05 14:38:48 +00:00
Ben Stull 2f5d09aef5 §22 spec: re-cut Part E into usable BDD-tagged slices (S1-S6)
Per operator (session 0072): every slice must end in a usable deployment and
declare which Part C scenarios it makes pass. Tag all 23 Gherkin scenarios with
@S<n> (the slice that completes them) and re-cut Part E from layer-by-layer
(N1-N6) to usable increments (S1-S6) with a slice->scenario index table. S1
bundles the coupled migration 029 + threading + redirect as one right-sized
first session; S2 second collection; S3 role enforcement; S4 invitation;
S5 create-project + directory; S6 types + SPEC merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:42:12 -07:00
Ben Stull 87279fc545 §22 three-tier spec Part E: decided migration-029 strategy (collection grain beneath project)
Operator chose (session 0072) to add the collection grain beneath today's
project: projects table stays the group tier (keeps content_repo), a new
collections table holds the per-corpus fields, entries re-key to
(collection_id, slug), one default collection per project on migrate, breaking
/p/<project>/e/<slug> -> /p/<project>/c/<collection>/e/<slug> with 308s.
Re-sloted slices N1-N6. Correction banners updated from pending -> decided.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:46:51 -07:00
Ben Stull 9c0e3b60ac Correct §22 three-tier spec: two-tier model already shipped (v0.39.0)
Re-checked code vs the stale memory: migration 028 (slug PK -> (project_id,
slug)), v0.35.0 /p/<project>/ routing, and v0.37/0.38 per-project read+propose
are all shipped to main. The 'fold into not-yet-shipped Plan B + M3-frontend'
premise is false. Neutralize the wrong claims in §0/§A.3 and flag Part E's
sequencing as pending re-decision; structural model (Parts A-D) unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:43:44 -07:00
Ben Stull 31d680be54 §22 three-tier refactor spec: project → RFC collection + unified roles + BDDs
Splits the original §22 two-tier model (deployment → project=corpus) into
three tiers (deployment → project → RFC collection). Project owns one
content repo; collections are typed subfolders declared by .collection.yaml
manifests (git-truth). Reconciles the accumulated role vocabulary onto one
{owner, contributor} enum attached at {global, project, collection}, with
downward additive inheritance and no negative override. Adds BDD scenarios
(Part C) for role usage, invitation, and empty states. Re-slots the roadmap
to fold the tier into the not-yet-shipped Plan B (mig 028) + M3-frontend.

Session 0072 (spec). Revises docs/design/multi-project-spec.md §22.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:39:04 -07:00
Ben Stull f758fe072f Merge pull request '§22.13 step 1: default-project-id re-stamp (v0.39.0)' (#17) from feat/m3-planb-restamp into main 2026-06-04 14:45:21 +00:00
Ben Stull 33c67ccc09 §22.13 step 1: default-project-id re-stamp (v0.39.0)
projects.restamp_default_project(config): at startup after the registry mirror,
renames project_id from the M1 bootstrap 'default' to the configured
DEFAULT_PROJECT_ID across every project-scoped table (discovered by column) and
drops the stale 'default' projects row, so a deployment's original corpus lands
at a meaningful /p/<id>/ and 'default' is never a public URL. FK off for the
rename (parent+children move together) + foreign_key_check backstop. Idempotent;
no-op unless DEFAULT_PROJECT_ID is set to a non-'default' value.

test_restamp_default_project.py (3 tests). 450 backend green.

This is the last framework piece for OHM's clean /p/ohm/ cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:45:10 -07:00
Ben Stull 508a8cb6d0 Merge pull request '§22 Plan B write path (propose): project-scoped propose (v0.38.0)' (#16) from feat/m3-planb-write into main 2026-06-04 14:02:29 +00:00
Ben Stull fec51bdbb6 §22 M3-backend Plan B (write path, propose): project-scoped propose (v0.38.0)
A new entry can be proposed into a specific project; it lands in that project's
content repo and shows under that project's proposals. A non-default project is
no longer read-only.

- api.py: POST /api/projects/{pid}/rfcs/propose (propose body extracted into a
  project-parameterized helper; unscoped /api/rfcs/propose kept as default
  compat). Slug uniqueness, idea-PR reservation, landing state, and the
  proposed_use_cases row scoped to the target project. GET
  /api/projects/{pid}/proposals.
- cache.py: refresh_meta_pulls loops every project's content_repo, stamping
  cached_prs.project_id; projects.content_repo(pid) helper.
- frontend: proposeRFC(projectId,…)/listProposals(projectId); ProposeModal
  takes projectId; App resolves current project from the /p/<id>/ URL; Catalog
  lists that project's proposals.
- tests: test_project_scoped_propose.py (lands scoped + gated 404). 447 backend
  + 11 Vitest green; clean build.

Known limitation: branch/PR/graduation edit flows + default-id re-stamp not yet
scoped (next slice). Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:02:19 -07:00
Ben Stull 455ef33b29 Merge pull request '§22 M3-backend Plan B (2/2, read): per-project RFC serving (v0.37.0)' (#15) from feat/m3-backend-planb2 into main 2026-06-04 13:45:52 +00:00
Ben Stull 9f548a340d §22 M3-backend Plan B (2/2, read path): per-project RFC serving (v0.37.0)
A second project's corpus now renders under /p/<id>/, isolated by its own
slug namespace. Completes step (1) "RFC app supports multiple projects" for
the read path.

- api.py: GET /api/projects/{pid}/rfcs (scoped catalog) + /{slug} (entry by
  (project_id,slug)), behind the §22.5 read gate. Unscoped /api/rfcs stay as
  default-project compat.
- cache.py: refresh_meta_repo iterates every projects row, mirroring each
  content_repo into cached_rfcs stamped with its project_id (_upsert_cached_rfc
  gains a project_id arg).
- frontend: api.listRFCs(projectId)/getRFC(projectId,slug); Catalog + RFCView
  pass useProjectId(); ProjectLayout's NotServedPlaceholder guard removed (every
  project serves), 404/not-readable branch kept; NotServedPlaceholder deleted.
- tests: test_project_scoped_serving.py (catalog scoping, entry isolation,
  gated 404); ProjectLayout.test updated (non-default renders). 445 backend +
  11 Vitest green; clean build.

Known limitation (next slice): write path + default-id re-stamp not yet scoped
(non-default project read-only); no live impact (no 2nd project in prod yet).
Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:45:42 -07:00
Ben Stull 569066ef48 Merge pull request '§22 M3-backend Plan B (1/2): slug-keyed PK rebuilds (v0.36.0)' (#14) from feat/m3-backend-planb into main 2026-06-04 13:30:48 +00:00
Ben Stull a117fbb521 §22 M3-backend Plan B (1/2): slug-keyed PK rebuilds activate project #2 (v0.36.0)
Lands the table rebuilds migration 026 deferred until a second project exists,
shipped separately from per-project serving for migration hygiene. No behavior
change (deployments stay single-project 'default').

- migration 028_project_scoped_keys.sql: folds project_id into the slug-keyed
  PK/UNIQUE of 13 tables (cached_rfcs PK (slug)->(project_id,slug); the
  UNIQUE/PK on cached_branches, branch_visibility, branch_contribute_grants,
  stars, watches, pr_seen, branch_chat_seen, funder_consents, rfc_collaborators,
  contribution_requests, proposed_use_cases gain project_id) and makes the FKs
  to cached_rfcs on rfc_invitations/rfc_collaborators/contribution_requests
  composite (project_id, rfc_slug) -> cached_rfcs(project_id, slug).
- db.run_migrations: a `-- migrate:no-foreign-keys` migration runs with
  PRAGMA foreign_keys toggled OFF around it (required by SQLite's table-rebuild
  procedure) + foreign_key_check after, failing loudly on dangling refs.
- ON CONFLICT upsert targets for the rebuilt tables gain project_id so they
  match the new composite indexes (value still defaults to 'default').
- test_migration_028_project_scoped_keys.py: proves two-project same-slug
  coexistence, within-project uniqueness, composite-FK enforcement. 442 pass.
- design doc §2 marked shipped.

Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:30:37 -07:00
Ben Stull d5213fc2da Merge pull request 'docs(design): M3-backend Plan B design (§22)' (#13) from docs/m3-backend-planb-design into main 2026-06-04 13:12:47 +00:00
Ben Stull 380e1f9782 docs(design): M3-backend Plan B — per-project serving, default-id re-stamp, PK rebuilds (§22)
The blueprint for the next §22 slice after M3-frontend (v0.35.0): make a
second project's corpus actually serve + render. Pins the three things that
land together — the default-project-id re-stamp (§22.13 step 1), the deferred
slug-keyed PK/UNIQUE rebuilds (migration 026 header, 12 tables), and
per-project RFC serving (path-scoped endpoints + per-project cache/bot/webhook
dispatch + scoped frontend calls + guard removal). Notes the Tier-1 registry
seed as the shared unblock for both this slice's and M3-frontend's deferred
e2e. Target v0.36.0 (possibly split B-1 read / B-2 write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:12:38 -07:00
Ben Stull db57caf8a1 Merge pull request '§22 M3 frontend: routing, runtime branding, directory, 308s (v0.35.0)' (#12) from feat/m3-frontend-impl into main 2026-06-04 12:59:41 +00:00
Ben Stull 999c4b65ef §22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)
Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:58:59 -07:00
Ben Stull 0252e40527 v0.34.0: containerize for per-PR preview environments (flotilla SPEC §15) 2026-06-04 01:52:50 -07:00
Ben Stull 97ba3ae9b5 M3-frontend design spec (#10)
§22 M3-frontend design: routing, runtime branding, directory/switcher, 308s. Doc only; impl plan to follow against the merged Plan A APIs.
2026-06-04 08:47:38 +00:00
Ben Stull 6c2bdb3c0a §22 M3 backend Plan A: registry mirror + runtime config (v0.33.0) (#11)
Registry mirror, GET /api/deployment + /api/projects/:id, initial_state/unreviewed semantics, migration 027 (additive), hard-cut META_REPO -> REGISTRY_REPO. Full suite 434 green; version 0.33.0.
2026-06-04 07:34:47 +00:00
Ben Stull 0f6b2b464b chore: wire always-on Wiggleverse org-context import into CLAUDE.md
Adds the @~/.claude/wiggleverse.md import so the org-wide agent context
auto-loads for future sessions in this repo (session-init step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:28:27 -07:00
Ben Stull f114af8ce0 docs(changelog): correct initial_state default (super-draft) + API field lists for 0.33.0 2026-06-04 00:19:56 -07:00
Ben Stull a2dc29af9c release: 0.33.0 — §22 M3 backend Plan A (registry mirror + runtime config)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:13:42 -07:00
Ben Stull 8004b2a123 refactor(review): sibling-consistent naming + module import; assert mark-reviewed git round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:10:37 -07:00
Ben Stull 69fd0cb2f0 fix(review): None-safe default_content_repo in mark-reviewed endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:04:08 -07:00
Ben Stull 87ddb845f4 docs(design): M3-frontend design — routing, runtime branding, directory/switcher, 308s (§22)
Frontend half of §22 M3, paired with the M3-backend spec. /p/<project>/ routing
(BrowserRouter + nested Routes + DeploymentProvider/ProjectLayout), N=1 redirect,
the VITE_APP_NAME->runtime hard cut (brandTitle fallback), per-project theme
overlay, real server-side 308 redirects, deployment directory + project switcher.
Scope boundary: corpus calls stay unscoped (default project only) with a guard
placeholder for non-served projects; second-project content is gated on Plan B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:59:17 -07:00
Ben Stull 76207bbb62 feat(review): mark-reviewed action + §7 catalog unreviewed filter (§22.4c)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:56:58 -07:00
Ben Stull 2fe2a719ac refactor(propose): resolve landing-state target via resolved_default_id 2026-06-03 23:52:37 -07:00
Ben Stull fe47eefdd9 feat(propose): honor project initial_state (§22.4b active landing + unreviewed) 2026-06-03 23:48:08 -07:00
Ben Stull e8ce3cd228 test(api): gated-project member-read positive path; guard config_json parse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:44:51 -07:00
Ben Stull 07e003e5fc fix(api): /api/projects/:id returns 404 (not 500) for unknown id incl. superusers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:39:27 -07:00
Ben Stull c386b05960 feat(api): GET /api/deployment + /api/projects/:id (§22.9 runtime config)
Adds two read endpoints that replace the build-time VITE_APP_NAME with a
runtime-config surface: /api/deployment returns deployment name/tagline plus
the projects visible to the caller (gated filtered by membership, unlisted
omitted from enumeration per §22.5); /api/projects/:id returns one project's
config and optional theme overlay, gated behind the §22.5 read gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:34:08 -07:00
Ben Stull 48fd6f9675 fix(registry): clean content_repo failure in hygiene; retire META_REPO from .env.example; clarity + idempotency test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:28:08 -07:00
Ben Stull cecc6c0b41 feat(registry): hard-cut META_REPO -> REGISTRY_REPO; wire mirror into startup/webhook/sweep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:16:51 -07:00
Ben Stull f1b03dffef Merge pull request 'M3-0: §10.3 Tier-1 test foundation (Vitest + Docker stack + Playwright e2e)' (#9) from feat/m3-0-test-foundation into main 2026-06-04 06:11:35 +00:00
Ben Stull 759a42e589 test(tier1): add e2e-install target; clarify limiter docs; label brand sample
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:04:54 -07:00
Ben Stull 2746242022 docs(testing): how to run Tier-1 local Docker and Tier-2 PPE
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:00:50 -07:00
Ben Stull f7bd466f31 fix(registry): RegistryError on non-dict entry; atomic apply; tighten type guard + slug regex
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:57:33 -07:00
Ben Stull 539d063c22 test(e2e): smoke spec — app loads and OTC sign-in succeeds via Mailpit
The first end-to-end smoke spec, run live against the Tier-1 stack
(§22 M3-0 Task 7). Drives the §6.2 email OTC sign-in: loads the app,
requests a code, reads it from Mailpit, verifies it, and asserts the
verify returns ok:true and sets the rfc_session cookie.

Supporting harness fixes:
- Makefile: add .PHONY so `make e2e` actually runs (the e2e/ directory
  was shadowing the target, making it a no-op).
- mailpit.js: guard the per-message detail fetch (if !full.ok continue)
  and scan the plain-text part before HTML so the \d{6} match can't
  latch onto a stray number.
- spec uses a unique per-run email to avoid the per-email request
  cooldown (OTC_REQUEST_COOLDOWN_SECONDS) on re-runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:57:02 -07:00
Ben Stull 27a0a0443b feat(registry): §22.2 projects.yaml parse/validate/apply module 2026-06-03 22:47:19 -07:00
Ben Stull 7d05125381 test(e2e): Playwright harness parameterized by BASE_URL + Mailpit mail sink
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:45:13 -07:00
Ben Stull 8f21dc5f9c feat(cache): mirror §22.4c review fields into cached_rfcs 2026-06-03 22:42:05 -07:00
Ben Stull 49b741243e test(tier1): docker compose stack (gitea seed + backend + web + mailpit)
Wires the four-service Tier-1 integration stack and brings it up green.

Fixes found during live bring-up:
- gitea-admin-init: run as user 'git'; the gitea CLI refuses to run as
  root and our custom entrypoint bypasses the image's s6 init that
  normally drops privileges.
- backend.Dockerfile: COPY VERSION to /VERSION; health.py reads the
  canonical VERSION file from the repo root (parents[2]) at import, so
  it must exist in the image or the backend crashes on startup.

env_file cold-start: generated/.env.tier1.generated is created as an
empty placeholder (gitignored) so the backend env_file path always
exists; the seeder overwrites it before backend starts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:41:49 -07:00
Ben Stull 2d9022b19e feat(db): migration 027 — additive §22 M3 schema (type/initial_state, deployment, review cols) 2026-06-03 22:35:39 -07:00
Ben Stull 0bccae1260 test(tier1): idempotent Gitea seed script (bot token, org, content repo, OAuth app, webhook)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:33:48 -07:00
Ben Stull d8661d5025 test(tier1): nginx web image serving the built SPA
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:31:03 -07:00
Ben Stull d73a9e2860 test(tier1): backend Docker image + env contract
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:28:39 -07:00
Ben Stull 597f6bc92b feat(entry): §22.4c unreviewed/reviewed_at/reviewed_by frontmatter fields 2026-06-03 22:28:11 -07:00
Ben Stull 3a3104f4c6 test(frontend): add Vitest unit-test harness with first brand helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:24:46 -07:00
Ben Stull dd72f913a3 docs: M3-backend Plan A implementation plan + spec refinements
Adds the bite-sized TDD implementation plan for Plan A (registry mirror +
runtime-config APIs + initial_state/unreviewed semantics; additive only).
Refines the spec with two planning discoveries: the re-stamp must be a
Python startup step (pure SQL can't read DEFAULT_PROJECT_ID), and the
PK-rebuild blast radius justifies splitting M3-backend into Plan A (this)
and Plan B (rebuild + project_id threading + re-stamp, before M4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:23:55 -07:00
Ben Stull 1b011ed483 docs(plan): M3-0 test & local-env foundation implementation plan (§22 / handbook §10.3)
Bite-sized TDD plan for the Tier-1 local-Docker test foundation: Vitest
frontend-unit setup, a four-service docker compose stack (seeded real
disposable Gitea + backend + nginx SPA + Mailpit), and a Playwright e2e
harness (BASE_URL + Mailpit mail-sink) with one OTC-login smoke spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:22:38 -07:00
Ben Stull 2cf7db4bff docs(plan): M3-0 test & local-env foundation implementation plan (§22 / handbook §10.3)
Bite-sized TDD plan for the Tier-1 local-Docker test foundation: Vitest
frontend-unit setup, a four-service docker compose stack (seeded real
disposable Gitea + backend + nginx SPA + Mailpit), and a Playwright e2e
harness (BASE_URL + Mailpit mail-sink) with one OTC-login smoke spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:07:50 -07:00
Ben Stull 6f356d3598 docs: M3-backend design spec (§22 registry mirror + data spine + APIs)
Brainstormed design for the backend half of §22 slice M3, split at the
backend/frontend seam. Covers migration 027 (type/initial_state columns,
the 12-table PK rebuilds, default→slug re-stamp), the app/registry.py
mirror over REGISTRY_REPO, GET /api/deployment + /api/projects/:id, and
the initial_state/unreviewed review semantics. Routing, runtime branding,
directory, and switcher are deferred to a later M3-frontend spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:59:20 -07:00
Ben Stull 7703fa233a docs(design): M3 implementation design — decomposition, decisions, test strategy (§22)
Brainstormed design for §22 slice M3 (registry mirror + routing + runtime
branding). Decomposes M3 into a §10.3 Tier-1 test/local-env foundation
(M3-0) plus four sequential feature sub-plans (M3a migration+restamp, M3b
registry mirror, M3c routing+redirects+branding, M3d landing-state/review).
Records resolved decisions: hard-cut branding, §4.1 reconciler pattern,
Stage-1 forward-only migration latitude with a future expand/contract note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:02:40 -07:00
Ben Stull 1dab24eef0 Merge feat/multi-project (§22 M1+M2) into main
Integrates the multi-project work so far — the §22 design drafts, M1 (the
schema spine), and M2 (project-scoped authorization + the §22.7 resolver) —
on top of the v0.32.0 retire/§13 changes that landed on main meanwhile.

Integration decisions:
- Renumbered the M1 projects migration 025_projects.sql -> 026_projects.sql.
  v0.32.0 shipped 025_retired_state.sql, which rebuilds cached_rfcs and
  predates the project_id column; running projects *after* the retire rebuild
  is required so project_id survives on a fresh database (the runner applies
  *.sql in filename order). Comment/doc references bumped to match.
- Resolved the get_rfc conflict in api.py by composing both gates: compute the
  viewer once, apply the §22.5 visibility gate, then v0.32.0's §13.7
  retired-entry owner-only check.
- api_graduation.py / api_discussion.py auto-merged cleanly (M2's threaded
  viewer/visibility gate coexists with the new retire endpoints + retired
  state).

Full suite 401 passed on a fresh DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 04:56:39 -07:00
Ben Stull 503689bf1a feat(projects): M2 — project-scoped authorization + the §22.7 resolver (§22)
Builds the three-tier authorization resolver on M1's schema spine: the
most-permissive union of deployment role (§6.1), project role (§22.6), and
per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on
top (§22.7). Pure app-layer — no migration (M1 shipped the tables), no
behavior change on the public default project. Verifiable on the single
default project by flipping its visibility and granting/revoking roles.

- app/auth.py: the §22.7 resolver primitives — project_visibility,
  project_member_role, project_of_rfc, is_project_superuser, can_read_project,
  effective write/discuss standing (can_contribute_in_project /
  can_discuss_in_project), require_project_readable, visible_project_ids.
  The three per-RFC capability helpers (can_discuss_rfc / can_contribute_to_rfc
  / can_invite_to_rfc) now compose all three tiers, so the ~20 call sites
  inherit M2 unchanged.
- Read pass: the §22.5 visibility gate (404 to non-members) threaded into
  every RFC-resolution helper across api.py / api_branches / api_prs /
  api_graduation / api_discussion / api_contributions / api_invitations, plus
  the catalog + proposals listings filtered by visible_project_ids.
- Write pass: the deep gates (branch read/contribute/owner, PR merge/withdraw/
  edit, graduation, discussion resolve) fold in project_admin via
  is_project_superuser; the "any-contributor" branch mode routes through
  can_contribute_in_project; propose + claim gate on project contribute
  standing. Deployment-level surfaces (admin idea-PR merge/decline, account
  notification-mute) stay deployment-scoped.
- Operator decisions: implicit-on-public (a granted deployment contributor
  keeps its pre-M2 write baseline on a public project, no membership row, so
  the N=1 case stays whole) and preserve-curation (that baseline does not
  override per-RFC owner curation — only an explicit project grant or a
  deployment owner/admin does), keeping the v0.16.0 per-RFC invite contract
  intact on public.
- tests: test_multi_project_authz_vertical.py — 9 vertical assertions on the
  resolver tiers, public-unchanged regression, the gated 404 read gate, the
  gated contribution gate, union + subtractive visibility, and revocation.
  Full suite 390 passed.
- docs: mark Part C M2 "(landed)" with the two operator decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:02:34 -07:00
Ben Stull ad2ece18fa docs(design): resolve the three M3-blocking decisions (§22 draft)
- Default project id: config-derived slug (DEFAULT_PROJECT_ID > slug of
  deployment name > 'default'); M1's 'default' bootstrap is re-stamped in M3
  before any /p/ URL is public, so it's meaningful (/p/ohm/) and never renamed
  live. §22.13.
- Entry-noun in URLs: generic /p/<project>/e/<slug> for every type; the noun
  (RFC/Spec/Feature) is a type-driven UI label, not in the path. §22.10.
- Existing RFC-NNNN: kept as a frozen, read-only legacy display label in
  frontmatter id (preserves citations); never used for lookup, never assigned
  to new entries. §22.4, §2.3 amended.

Threads through §22.4/22.10/22.13, the §2.3 amendment, and the M3 slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:03:15 -07:00
Ben Stull 57b2fc5205 docs(design): project types, slug-only identity, initial_state + unreviewed flag (§22 draft)
Extends the §22 multi-project draft along four axes decided this session:

- Project type — each project declares document | specification | bdd
  (immutable, registry-set). One shared propose→branch→PR→graduate engine;
  type only layers entry schema, terminology, and type-specific surfaces
  (spec release-planning, BDD scenarios/coverage). §22.4a.
- Slug-only identity — an entry is (project_id, slug); the per-project
  RFC-NNNN numbering and its allocator are retired. §22.4, §2.3/§13 amended.
- initial_state — per-project landing state for a new entry: super-draft
  (document/spec default) or active (bdd default). §22.4b.
- unreviewed flag — entries that skip straight to active land unreviewed;
  graduation implies review so the normal path is never flagged. Owner
  mark-reviewed clears it; §7 catalog gains an unreviewed filter; mirrored
  into cached_rfcs. §22.4c.

Slicing plan re-sliced six→seven: type/initial_state/unreviewed config ride
M3, type surfaces become M5, membership→M6, hardening→M7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:26:52 -07:00
Ben Stull 34a65e099e deploy(nginx): terminate TLS with the *.wiggleverse.org wildcard, drop per-host certbot
Restructure the ohm vhost from the pre-certbot template into an explicit
80->443 redirect + hand-managed 443 TLS block, pointed at the wildcard cert
installed on the VM (/etc/ssl/certs/wiggleverse-wildcard.crt + the key under
/etc/ssl/private). Adds modern ssl_protocols/session settings that previously
came from certbot's managed snippet. All CSP/HSTS/security headers, root, and
locations preserved verbatim. Enables flipping ohm to the Cloudflare orange
cloud (SSL mode Full strict). Cert files must be installed before reload;
retire the old cert with `certbot delete` once cut over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:57:02 -07:00
Ben Stull 848de4cd8a Merge pull request '§13: make graduation's RFC number optional; add retire (soft delete)' (#7) from feature/v0.32.0-optional-grad-id-retire into main
Reviewed-on: https://git.wiggleverse.org/ben.stull/rfc-app/pulls/7
2026-06-02 05:29:19 +00:00
Ben Stull 49ba06e0c2 §13: make graduation's RFC number optional; add retire (soft delete)
Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).

Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).

Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:27:46 -07:00
Ben Stull 6f901e3e2c feat(projects): M1 — the multi-project schema spine (§22)
Lays the additive foundation for hosting N projects per deployment, with
today's single corpus as the N=1 case. No behavior change: the app runs
exactly as before, single project, with the spine underneath.

- migration 025: `projects` + `project_members` tables; seed the `default`
  project (visibility=public, preserving open-by-default); thread
  `project_id NOT NULL DEFAULT 'default'` onto all 19 slug-bearing tables,
  backfilling existing rows. Additive — no table rebuilds; the slug-keyed
  uniqueness/PK rework is enumerated in the migration header and deferred to
  the slice that activates project #2.
- app/projects.py: §22.13 startup backfill of the default project's
  content_repo from META_REPO (idempotent — never clobbers a value the
  future registry mirror sets).
- config: REGISTRY_REPO wired (optional through M1; consumed by the M3
  mirror), documented in .env.example as META_REPO's successor.
- tests: 6 vertical assertions on the spine (seed, backfill, column shape,
  role CHECK, idempotency, config). Full suite 381 passed.
- docs: align Part C M1/M3 boundaries with the landed code (registry mirror
  + redirect move to M3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:14:53 -07:00
Ben Stull 7aba89655d docs(design): sketch multi-project deployments + draft §22 spec & slicing plan
Design for hosting N projects (corpora) per deployment, with today's
single-corpus deployment as the N=1 case. Captures the locked decisions
(git registry, gated-by-default visibility, per-project RFC numbering),
the three-tier role model (deployment / project / per-RFC), and the
forced runtime-branding shift off VITE_APP_NAME.

- multi-project.md: rationale, decisions, operator (flotilla) integration.
- multi-project-spec.md: draft binding §22, in-place amendments to
  §§1,2,5,6,7,8,13,14,17,18,20, and a six-slice (M1-M6) build plan.

Registry lives in a deployment-side repo the framework reads via a new
REGISTRY_REPO env var (META_REPO's multi-project successor); confirmed
against the ohm-rfc-app-flotilla spec — no operator-tooling change needed.

Not yet merged into SPEC.md or versioned; folds in when M1 lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:05:13 -07:00
Ben Stull 0062510a4e Merge pull request 'docs(deploy): correct stale infra facts after GCP name-alignment' (#6) from docs/deploy-infra-realignment into main 2026-06-01 17:16:27 +00:00
Ben Stull 714c2aed86 docs(deploy): correct stale infra facts after GCP name-alignment
The deploy docs predated the GCP name-alignment and described
infrastructure that no longer exists. Discovered during the v0.31.4
deploy. Corrections:

- Project wiggleverse-rfc -> wiggleverse-ohm; VM rfc-app -> ohm-rfc-app;
  install path /opt/rfc-app -> /opt/ohm-rfc-app; system user + service
  rfc-app -> ohm-rfc-app; external IP 34.132.29.41 -> 136.116.40.66.
- SSH is now IAP-only (direct port 22 times out): document
  --tunnel-through-iap on every gcloud compute ssh.
- Meta repo wiggleverse/meta -> wiggleverse/ohm-content.
- Two-remote reality: the VM's git origin is git.benstull.org/benstull/
  rfc-app, a SEPARATE Gitea from the release one (git.wiggleverse.org)
  that does not auto-mirror — so a release must be pushed to the
  benstull remote before the VM can fetch it. The VM tracks a detached
  release TAG, not a branch.
- Frontend-only changes are live once dist/ is rebuilt (nginx serves it
  directly); pip install only when requirements.txt changed.

Docs-only; no version bump (cf. the docs-only commit after v0.31.3).
The First-Time Deployment blocks keep the structural commands with a
substitution note rather than unvalidated rewrites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:15:58 -07:00
Ben Stull 551d240967 Merge pull request 'Release v0.31.4: fix invisible light-surface btn-link + harmonize RFC breadcrumb action bar' (#5) from release/v0.31.4-button-ux into main 2026-06-01 15:20:33 +00:00
Ben Stull 76c82a5e96 v0.31.4: fix invisible light-surface btn-link + harmonize RFC breadcrumb action bar
`.btn-link` was a dark-header utility (white text on translucent white)
reused on light surfaces — the RFC breadcrumb action bar, PR diff toggle,
modals, discussion panel — where it rendered white-on-white and looked
like missing buttons (reported: "Metadata"/"Claim ownership"/"Invitations"
absent on a super-draft header). Root-cause fix:

- Base .btn-link is now a light-surface secondary button (white fill,
  hairline border, dark label); the dark-header "Sign out" keeps the
  translucent-on-dark treatment via an .app-header .btn-link scope.
- .breadcrumb-actions normalizes the toggle, filled CTAs, and secondary
  buttons to one height/radius/type as a single control group, and
  flex-wraps instead of clipping off the right edge.
- Diff-mode active toggle now reads as clearly selected (filled ink).

CSS-only; patch release, plain frontend rebuild applies it. No upgrade
steps. Driver session 0059.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:22:19 -07:00
Ben Stull e8e555d8a4 docs(invites): correct stale last_seen_at NULL claim
The provisioning docstring claimed the invitee row gets
`last_seen_at = NULL` as the "not yet arrived" discriminator. It does
not: the column is NOT NULL and the INSERT omits it, so it defaults to
datetime('now') — the longer note below already explained this, but the
bullet contradicted it. Rewrite the bullet to state the real behavior
(both timestamps default to now; the pending-invite state lives in the
unclaimed user_invite_tokens row) and note that consumers must treat a
pending-invite row as never-seen. Comment-only; no runtime change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:04:11 -07:00
Ben Stull 7e595b6e5e v0.31.3: admin Users "Last seen" = Never for unclaimed invites
An admin-created invite row showed a Last-seen timestamp identical to
Signed-up, implying the invitee had visited. users.last_seen_at is
NOT NULL DEFAULT (datetime('now')) and the invite INSERT sets neither
timestamp, so both default to row-creation time; last_seen_at only
advances on real authentication. An unclaimed invite has provably never
authenticated (the unclaimed state drives the PENDING INVITE badge), so
the Users tab now renders "Never" for the Last-seen cell of a pending
row. Signed-up (invite-created date) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:00:31 -07:00
Ben Stull 1d716d0cb8 v0.31.2: landing welcome panel spacing
Visual-only patch. The "/" welcome read-view was jammed against the
catalog divider with no top offset and loose paragraph rhythm: the
.main-pane §8 override (padding:0; display:flex) shadows the padded
read-view rule, so the pane gives no padding, and .welcome (max-width
only) never compensated. The welcome surface now owns its breathing
room — 56px top / 48px side gutters, a 680px measure, a text-3xl hero,
and even --space-8 paragraph spacing at --leading-relaxed. Covers both
the signed-out and signed-in welcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:54:29 -07:00
Ben Stull f96883506e v0.31.1: admin Users tab + header UX polish
Visual-only patch atop v0.31.0. CSS plus markup/structure in Admin.jsx;
no API, schema, config, overlay, or secret change — a plain frontend
rebuild applies it.

Two latent CSS defects fixed:
- .invite-badge had no rule, so "(pending invite)" rendered as bare
  parenthetical text; it's now a quiet amber pill.
- .btn-link-quiet never reset native <button> chrome, so the admin
  Revoke/Grant/Remove buttons, the modal close ×, and Login/BetaPending
  link-buttons kept the browser's grey button box. The reset the
  .otc-login scope already carried is folded into the base rule.

Users tab: table headers no longer wrap (WRITE-MUTED), timestamps
render as a date-over-time stack, the duplicated subline email is
de-duped, the Create-user-+-invite action moves flush-right beside the
title, and intro DB-column refs read as quiet chips.

Header: the Inbox (§15.2) trigger was styled for a light surface
(gray-200 border, gray-50 hover) and rendered as a pale box that went
white-on-white on hover; restyled to the nav-link vocabulary
(borderless, gray-300 icon → white on a faint translucent hover).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:32:47 -07:00
Ben Stull 0c972c8af5 v0.31.0: meta-only repository topology (ROADMAP #36)
Retire the per-RFC-repo model. RFCs now live in their meta-repo entry
(rfcs/<slug>.md) for their whole life; graduation is an in-place
super-draft → active state flip that keeps the body in the entry — no
repo creation, no body-strip, no five-step transaction, no rollback.

SPEC: §1 topology rewritten (one meta/content repository, no per-RFC
repos) with a deployer-facing "single content repository" framing; §2
(repo: always-null), §3 (active is in-place), §4, §9.8 (handoff
frictions dissolve), and §13 fully rewritten (two-field dialog, "The
flip", §13.6 RFC-0001 fold-back record).

Code: graduation collapses to open+merge one frontmatter PR; branch/PR/
chat dispatch re-keyed on meta-residency (repo IS NULL) so active RFCs
edit on the meta repo exactly as super-drafts do; the two "RFC has no
repo" 409 guards removed; promote-to-branch slug-embeds an active RFC's
auto-branch (edit-<slug>-<hex>) for shared-repo cache attribution;
refresh_meta_branches + hygiene branch-resolution include meta-resident
actives; the §9.8 read-only guard + pre_graduation_history scoped to
legacy per-repo only; dead bot primitives + GraduateDialog repo field +
blocking-PR popover removed. The repo: frontmatter field and the
/blocking-prs endpoint are retained (schema stability / informational).

Tests: graduation suite rewritten to the flip model; e2e + hygiene
updated. Full backend suite 375 passed; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:12:06 -07:00
Ben Stull d581010063 v0.30.2: header nav label "Philosophy" -> "About"
Revert the §14.3 persistent chrome link's display text from "Philosophy"
back to "About" (relabeled in v0.21.0). Display text only — route
/philosophy, the header-about class, and the page are unchanged. Patch
per SPEC §20.2: cosmetic, no deployment action beyond a frontend rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:56:14 -07:00
Ben Stull 732b23b156 v0.30.1: fix phantom pending-idea after merge-with-branch-delete
refresh_meta_pulls / refresh_rfc_repo recover a PR's slug from its
Gitea head.ref, which collapses to the refs/pull/<N>/head sentinel
once a merged PR's branch is deleted. The slug then parsed to None,
the row was skipped, and cached_prs.state froze at 'open' — so the
entry showed as both a super-draft and a pending idea. Recover the
real branch name from the stored cached_prs row when Gitea reports an
empty or sentinel ref.

Surfaced via the ROADMAP #35 operator authoring lane (CLI merge with
--delete-branch); the web UX leaves branches in place so it never hit
this. Regression test added; full suite 375 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 05:26:00 -07:00
Ben Stull 1558cc3a8b Release v0.30.0: sync user guide (DOCS.md) with the shipped app
Documentation-only minor. The guide had drifted since it was first
written; brought back in sync with v0.7.0–v0.29.0:

- "Signing in" rewritten: email + one-time-code, optional passcode,
  trust-device 30d, optional Turnstile, and the beta-request → pending
  → admin-grant gate, plus admin-create + invite-claim. The vestigial
  email allowlist is no longer described as the gate.
- "Proposing a new RFC": four → five fields (optional use-case #26) +
  AI tag-suggestion disclosure (#27).
- "Roles & permissions": documents the pending state.
- New "Invitations, cross-references, and contribution requests"
  section (#12 owner invites; #28 auto-link / create-RFC / ask-to-
  contribute).
- New "Privacy and cookies" section (#11/#13).

No code/schema/API/config/overlay/secret change — DOCS.md is served
verbatim by /api/docs. VERSION + frontend/package.json bumped to 0.30.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:12:18 -07:00
Ben Stull 8a94e26f75 Merge pull request 'Release v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests' (#3) from feature/v0.29.0-pr-rfc-create-contribute into main 2026-05-29 03:11:02 +00:00
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:13 -07:00
Ben Stull 019c8a9185 Merge pull request 'Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile siteverify)' (#2) from feature/v0.28.0-email-turnstile-async into main 2026-05-29 02:42:05 +00:00
Ben Stull 79a447c77b Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile)
Two informational findings from the Session-0026 audit, both
framework-internal defense-in-depth. No operator action: no migration,
no schema/config/overlay change, no deployment-facing surface.

- I3: guard the dead text/html branch in email_envelope.build_envelope.
  No send path passes body_html; the unused branch would emit HTML built
  from possibly-unescaped user content (C1 stored-XSS class in the mail
  channel). Passing body_html now raises NotImplementedError; the arg is
  kept for documented future symmetry, enabling HTML mail becomes a
  deliberate escape-then-unguard change.

- I4: make turnstile.verify_token async. The sync httpx.post ran inside
  the async /auth/otc/request handler, blocking the event loop up to the
  10s timeout on a slow CloudFlare call. It now awaits httpx.AsyncClient
  via a narrow _siteverify_post seam (tests patch the seam, not the
  shared AsyncClient). The sole caller (main.py) now awaits it.

Tests: full backend suite 365 passed. Added a coroutine-contract unit
test for verify_token and flipped the email_envelope HTML test to assert
the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 19:12:16 -07:00
Ben Stull fe044ed3db Merge pull request 'Release v0.27.0: security hardening (audit 0026)' (#1) from feature/v0.27.0-security-hardening into main 2026-05-29 01:28:42 +00:00
Ben Stull bd3ef269d4 Release v0.27.0: security hardening (audit 0026)
Remediates the rfc-app application + deploy-config findings from the
Session 0026 security audit. Cut as the "v0.25.0-security-hardening"
branch (from v0.24.0); reversioned to 0.27.0 on rebase onto main since
v0.26.0 (#28) shipped while this was in flight.

- C1 (Critical): single sanitizeHtml.js chokepoint (DOMPurify) for every
  marked→innerHTML / dangerouslySetInnerHTML sink (MarkdownPreview,
  ProposalView x2, Editor); rel=noopener hook on target=_blank links.
- H1: per-account OTC-verify lockout (migration 023, auto-applied) +
  per-IP throttle via new ratelimit.py; wired on otc verify/request +
  passcode check/verify.
- M1: device_trust.lookup() single indexed-row read — cookie value is now
  "<row_id>.<raw_token>"; bcrypt-checks one row, not a global table scan.
  (Behavior change: existing device-trust cookies re-prompt once.)
- M2: HTTP security headers (CSP/HSTS/XFO/XCTO/Referrer-Policy) at nginx.
- M4: session cookie Secure-by-default (SESSION_COOKIE_SECURE opt-out).
- M5: bounce webhook fails CLOSED (503) when secret unset, instead of open;
  RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 dev opt-in.
- L2/L3: per-IP cooldown + check-endpoint throttle.
- L4: systemd sandbox knobs. L8/I1: nginx server_tokens off + TLS1.0/1.1 out.

VERSION + frontend/package.json → 0.27.0; CHANGELOG documents the upgrade
steps (incl. the out-of-band nginx + systemd apply, which the flotilla
deploy gesture does not perform).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:10 -07:00
Ben Stull 698821f065 Merge feature/v0.26.0-pr-rfc-links (#28 Part 1: auto-link accepted RFCs in PR text + comments) 2026-05-28 16:21:26 -07:00
Ben Stull e794523079 v0.26.0: auto-link referenced RFCs in PR text + comments (roadmap #28 Part 1)
Part 1 of item #28: references to existing accepted RFCs inside PR
descriptions and comment text now render as inline links to the
referenced RFC. Parts 2 (offer-to-create) and 3 (offer-to-contribute-
to-pending) are deliberately deferred — Part 1 ships first as the easy
win, per the roadmap row.

Shipped in parallel with the v0.25.0 security-hardening session; this
took the next free version slot (0.26.0) per the roadmap's
"claims the next available version number" rule. Expect a top-of-file
CHANGELOG/VERSION merge with 0.25.0 — distinct concerns, trivial to
resolve.

Backend:
- rfc_links.py (new): builds a term index from the live accepted
  (state='active') RFC corpus and segments plain text into text /
  rfc-link segments. Conservative matching — links only rfc_id tokens
  (RFC-0001), multi-word titles (Open Human Model), and hyphenated
  slugs (open-human-model); a single common-word title/slug is NOT
  linked (would turn every prose "human" into a link). Case-insensitive,
  word-boundary-anchored, longest-match-wins, self-reference suppressed.
- api_prs.py get_pr(): enriches the PR description (description_segments)
  and every PR comment (text_segments).
- api_discussion.py: enriches PR-less discussion comments (text_segments).

Read-time, not submit-time: the roadmap says "at submit time" but the
intent it names is "not as live compose preview", which read-time
honors. Chosen for correctness (links track the live active set —
newly-accepted RFCs start linking, withdrawn ones stop), zero migration,
and cheapness (small cache-resident corpus). Recorded as a §19.3-rule-2
note in the session transcript.

Frontend:
- LinkedText.jsx (new): maps backend segments onto React text nodes +
  anchors. No dangerouslySetInnerHTML — XSS-safe by construction,
  independent of any HTML-sanitization layer. Falls back to raw text
  when segments are absent.
- PRView.jsx: description + PR conversation comment bodies render via
  LinkedText.
- RFCDiscussionPanel.jsx: discussion comment bodies render via LinkedText.
- App.css: .rfc-autolink (subtle accent + dotted underline, tokenized).

Tests: 12 new (test_rfc_links_vertical.py) — 9 scanner units + 3
end-to-end (PR description / review comment / discussion comment all
surface *_segments; self-reference suppression). Full suite 363 green;
frontend builds clean.

No upgrade steps: additive, no migration, no secret, no config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 16:17:01 -07:00
Ben Stull 28015ed1a2 Release v0.24.0: Claude Haiku tag suggestions on propose-RFC (#27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:28:12 -07:00
Ben Stull 376a6daddc Merge feature/v0.24.0-haiku-tags (#27: Claude Haiku tag suggestions on propose-RFC) 2026-05-28 13:25:50 -07:00
Ben Stull fb9b4fa422 v0.24.0: Claude Haiku tag suggestions on propose-RFC (roadmap #27)
The §9.1 Slice-2 AI-suggested tag chips, deferred since the propose
modal landed, now wired up. As the propose-RFC draft fills in, the
backend asks Claude Haiku for tags drawn ONLY from the corpus's
existing tag set (the de-facto taxonomy — v1 tags are free-form chip
input, there is no curated list), surfaced as clickable suggestion
chips. Nothing auto-applies; the user clicks to add.

Backend:
- tag_suggest.py: universe gather (distinct corpus tags, most-common
  first), Haiku prompt + tolerant reply parser (drops invented tags,
  dedupes, clamps confidence), in-process per-user rate limit.
- providers.construct_haiku(): dedicated Haiku provider from the
  operator key, independent of ENABLED_MODELS — tag suggestion always
  uses the cheap+fast model. No RFC slug at propose time, so the §6.7
  funder path does not apply.
- POST /api/rfcs/suggest-tags: contributor-gated, rate-limited.
  Degrades to an empty list (never an error) when no Anthropic key is
  bound, the corpus has no tags, or the draft is empty.

Frontend:
- ProposeModal: debounced suggestion fetch (700ms, stale-response
  guarded), clickable suggestion chips, and the required inline
  disclosure that the draft text is sent to Anthropic.
- api.suggestTags(): forgiving — any non-OK resolves to [].

Tests: 11 new (vertical contributor-gating / filtering / no-key /
empty-corpus / 429, plus units for gather, parser tolerance, max,
short-circuit, provider-failure). Full suite 351 green.

New secret on deploy: ANTHROPIC_API_KEY (see CHANGELOG Upgrade steps).
Disclosure copy wants a counsel pass before deploy, per #22 discipline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:25:39 -07:00
Ben Stull daebb54f47 Release v0.23.0: server-side sign-in state resume (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:37:21 -07:00
Ben Stull a598221812 Merge feature/v0.23.0-signin-resume (#29: server-side sign-in state resume) 2026-05-28 12:35:46 -07:00
Ben Stull bada72f87e v0.23.0: server-side sign-in state resume (roadmap #29)
Track each authenticated user's last-viewed route + light view state
server-side, and on next sign-in redirect them to that state, falling
back to the empty-state home only when there's no recorded state.

Backend:
- migration 022_user_session_state.sql: one row per user
  (user_id PK/FK, last_route, last_route_state JSON-as-TEXT,
  resume_enabled default 1, last_updated_at).
- PUT /api/me/last-state (require_user): upserts route + light state;
  no-ops when resume_enabled=0. Localized in the /me region.
- /api/auth/me payload now carries resume_enabled + last_route +
  decoded last_route_state (no extra round-trip).
- test_session_resume_vertical.py: auth-required, upsert/read-back,
  per-user isolation, resume_enabled=0 disable.

Frontend:
- lib/useLastState.js: debounced (~1s) route-change PUT for
  authenticated users; one-time resume redirect on sign-in, gated on
  identify having fired (preserves #21 Part C identify-then-track).
- api.js: putLastState() client call.
- App.jsx: import + call the hook; set identifyReady after identify.
  Header region untouched.

Per-user (not per-device); profile-settings opt-out toggle UI
deferred (column + default-on behavior ship now). Stored state is
route + light view state ONLY, never draft buffers — documented in
SPEC §6.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:34:19 -07:00
Ben Stull 7d8371dea1 Release v0.22.0: optional propose use-case field (#26)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:32:34 -07:00
Ben Stull 3a51425ec7 Merge feature/v0.22.0-usecase-field (#26: optional propose use-case field) 2026-05-28 12:30:36 -07:00
Ben Stull 7c6c906db2 #26: optional proposed-use-case field on propose-RFC + propose-PR
Adds an optional "What will you be using this for?" capture as a sibling
to the required justification on both propose surfaces, per roadmap #26.

- propose-RFC modal (ProposeModal): optional textarea below the required
  "Why is this RFC needed?" pitch, labeled "What will you be using this
  RFC for? (optional)".
- propose-PR modal (PRModal): optional textarea below the required
  description, labeled "What will you be using this change for?".
- Backend: ProposeBody / OpenPRBody gain an optional `proposed_use_case`
  (NULL/omitted accepted, no min, 8000-char cap matching the existing
  free-text bound). Persisted to a new canonical side table
  `proposed_use_cases` keyed by PR number, mirrored onto the cache
  columns added by migration 021. Returned on the proposal list/detail,
  RFC detail (by slug), and PR detail endpoints.
- Display: ProposalView, RFCView (main only), and PRView render the
  captured use case with a muted "left blank" treatment when NULL.
- migration 021: nullable `proposed_use_case` on cached_rfcs/cached_prs
  plus the reconcile-proof `proposed_use_cases` truth table.
- New vertical test_proposed_use_case_vertical: persists+returns when
  supplied, accepted as NULL/omitted, for both surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:52 -07:00
Ben Stull 2ac20b1621 Merge feature/v0.21.0-ux-polish (UX-polish wave: #31 + #24 + #25 + #32) 2026-05-28 11:52:05 -07:00
Ben Stull 493d6b6eee Release v0.21.0: UX-polish wave (#31 foundation + #24 + #25 + #32)
Token foundation + App.css sweep, header Philosophy rename, inbox
inline-SVG icon + light UX pass, session/transcript page collapse +
metadata header. Pure frontend; 332 backend tests green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:36:55 -07:00
Ben Stull 959fc906de Merge feature/v0.21.0-docs-32 into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull b648b3ed45 Merge feature/v0.21.0-header-inbox into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull 54736de91c Merge feature/v0.21.0-appcss-sweep into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull adb5d25715 docs(#32): inline-collapse session roots + transcript metadata header
Roadmap item #32 (session/transcript page polish) plus the /docs
surfaces' share of #31, for rfc-app v0.21.0.

- DocsSessionIndex: the session root (/docs/sessions/:nnnn) no longer
  renders a dead-end "N transcript(s) — select from the nav"
  placeholder. It now renders a transcript INLINE: the lone transcript
  for single-transcript sessions, or the `.0` driver transcript (falling
  back to first-by-sort) for multi-transcript sessions, with the
  remaining siblings listed/linked above the body. URL stays stable to
  the session number — inline render, no 301.

- DocsSessionTranscript: adds a compact metadata header above the body
  (title; started/ended parsed from the filename's ISO segments,
  human-readable; derived duration; optional TL;DR from the manifest's
  `tldr` string field, graceful-degrade when absent; external
  "View source on git.wiggleverse.org" link). The parse/header helpers
  are exported so the inline-collapse view reuses identical rendering.

- Docs.css (new): token-based styling for the new metadata-header +
  sibling-list elements only; existing docs classes stay owned by
  App.css to avoid racing the #31 sweep.

- DocsUserGuide: loading/error states brought onto the shared
  .docs-empty/.docs-error convention with a retry button.

No backend change: /api/docs/sessions/manifest already passes the full
sessions.json entry through, so a `tldr` field on an entry reaches the
frontend with no docs_sessions.py change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:33:03 -07:00
Ben Stull cbf02d5507 v0.21.0: sweep App.css + index.css to design tokens (#31)
Replace hardcoded colors, font-sizes, and radii in App.css/index.css
with the tokens.css design-token system. Consolidate ~80 distinct
hex values onto the neutral ramp + semantic/status families, map
font-size literals to the --text-* scale and border-radius literals
to the --radius-* scale, route the on-dark translucent-white pattern
and header band through their semantic tokens, and point the base
rules at --color-bg/--color-text/--font-sans.

Add an appended interaction-polish layer: a coherent transition
vocabulary (var(--motion-base) var(--ease-out)) on surfaces that
already react to hover, plus one consistent :focus-visible ring using
var(--color-focus-ring). No existing selector renamed or removed;
only property values changed and additive rules appended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:32:17 -07:00
Ben Stull 317738ed79 v0.21.0 header+inbox UX (#24,#25,#31): rename About→Philosophy, inline-SVG inbox icon, light inbox pass
- #24: header link "About" → "Philosophy" (route/title unchanged).
- #25 icon: replace 📮 emoji with a dependency-free inline-SVG envelope
  in .inbox-trigger; aria-label/title reframed to "Inbox". Badge intact.
- #25 inbox UX (light, no redesign): sharper unread/read distinction
  (accent dot + left bar + tint via tokens), per-row "mark as read"
  affordance that marks-without-navigating, clearer "Mark all read"
  label, and a real empty/caught-up state. New Inbox.css is tokenized
  and written one notch more specific than App.css where it overrides
  (Inbox.css injects before App.css under ESM eval order). Data flow,
  API calls, routing-on-click, and badge behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:31:34 -07:00
Ben Stull 5be2c48afe v0.21.0 foundation: design-token module (#31)
Establish src/styles/tokens.css — the single source of truth for color,
type, spacing, radius, elevation, and motion. Imported first in main.jsx.
Sweep subagents map literal values to these tokens; no appearance change
is intended beyond consolidating near-duplicate grays (operator reviews
before deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:26:56 -07:00
Ben Stull cbc9949972 Merge feature/v0.20.0-docs-specs-nav-hierarchy 2026-05-28 10:48:38 -07:00
Ben Stull e0d9ed7c5a Release v0.20.0: /docs/specs surface + nested flyout nav + session body-list removal
Wave 9 follow-up to roadmap item #30 (Session 0017.0 shipped #30 as v0.19.0;
v0.20.0 lands the operator-feedback follow-ups on top).

1. Specs on /docs/specs/<name> (backend docs_specs.py + frontend DocsSpec.jsx
   + DocsSpecsIndex.jsx). Configured via OHM_DOCS_SPECS; framework default
   carries OHM's two specs (rfc-app/SPEC.md + flotilla SPEC.md). Runtime
   fetch from gitea raw with 5-min TTL cache, mirroring docs_sessions.py.

2. Nested flyout nav hierarchy (DocsLayout.jsx). Sessions render as a tree
   with transcripts nested under each session row (labeled by .N ordinal).
   New Specs section between User Guide and Sessions.

3. /docs/sessions/<NNNN> body-list removed (DocsSessionIndex.jsx). Body
   becomes a session-overview card; navigation lives in the left nav.

19 new pytest cases for docs_specs (332 backend total green). Frontend
build clean. Sync frontend/package-lock.json version drift (0.15.0 → 0.20.0)
alongside the VERSION + package.json bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:48:31 -07:00
Ben Stull 69a166a6f2 Merge feature/v0.19.0-docs-sessions-browser 2026-05-28 09:14:56 -07:00
Ben Stull bb5137f176 CHANGELOG: normalize 0.19.0 header (drop 'v' prefix to match surrounding style) 2026-05-28 09:11:58 -07:00
Ben Stull 477f496cbf Release v0.19.0: /docs nav + on-site sessions browser
VERSION + CHANGELOG bump for roadmap item #30. The frontend
package.json was bumped alongside the frontend slice; this commit
finalizes the canonical VERSION at 0.19.0 and prepends the v0.19.0
CHANGELOG entry with the operator upgrade-steps block
(OHM_SESSION_HISTORY_RAW_BASE + the two TTL knobs; all MAY) and the
note about graceful degradation when the session-history repo is
still flat at deploy time (subsession 0017.2 ships the restructure
in parallel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:07:27 -07:00
Ben Stull 822f4266f6 v0.19.0 frontend: /docs/* route tree + flyout nav + sessions browser
Reorganizes the /docs surface from a single DOCS.md route into a hub
with a left-side flyout nav and three new sub-routes for the on-site
sessions browser (roadmap item #30):

  /docs                      → redirect to /docs/user-guide
  /docs/user-guide           → existing DOCS.md content
  /docs/sessions             → redirect to /docs/sessions/about
  /docs/sessions/about       → README.md of ohm-session-history
  /docs/sessions/<NNNN>      → per-session transcript index
  /docs/sessions/<NNNN>/<f>  → per-transcript view

The flyout is a persistent left sidebar on desktop and a slide-out
drawer on mobile (toggled by a ☰ button in the docs header). Its
session list is driven by the /api/docs/sessions/manifest fetch —
loading shows a skeleton; 502 shows an inline retry; empty manifest
shows only the "About" row.

Each sub-route owns its own empty-state / error handling:
  - 404 transcripts render "This transcript isn't published yet"
    with a link back to the parent session index, no JS crash.
  - 502 (gitea unreachable) renders a retry button.
  - Manifest 404 is mapped to {} server-side so the flyout renders
    cleanly with no error banner when no sessions are published yet.

Analytics (per SPEC §21):
  - new EVENTS.DOC_VIEWED ("Doc Viewed") fires on each sub-route
    mount with `section`: 'user-guide' | 'sessions/about' |
    'sessions/<NNNN>' | 'sessions/<NNNN>/<filename>'.
  - every interactive nav element carries aria-label +
    data-amp-track-name so autocapture rows are readable.

The existing v0.14.0 Docs.jsx component is dropped — its content
moved verbatim into DocsUserGuide.jsx; the new layout subsumes the
back-button + signed-out home affordances it used to carry. All
four new sub-routes reuse the existing MarkdownPreview renderer
(marked + mermaid lazy-load) so no second markdown library lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:07:20 -07:00
Ben Stull 39e57706d9 v0.19.0 backend: /api/docs/sessions/* endpoints + TTL cache
Adds the four read endpoints the v0.19.0 /docs/sessions/* surface
mounts on top of:

- GET /api/docs/sessions/manifest      → sessions.json (title map)
- GET /api/docs/sessions/about         → README.md
- GET /api/docs/sessions/<NNNN>/index  → per-session transcript list
- GET /api/docs/sessions/<NNNN>/<file> → transcript body

The framework mediates the gitea fetch so the rendered surface
inherits the same chrome as the v0.14.0 /docs route and the browser
makes no cross-origin call. Reads are aggressively cached in-process
(60s for the manifest, 5min for content) so the framework doesn't
hammer git.wiggleverse.org under normal traffic. Negative results
(gitea 404) are also cached at the content TTL to absorb the
expected empty-state at deploy-time (the parallel
ohm-session-history repo restructure ships in driver subsession
0017.2). All four endpoints are anonymous-reachable, sibling to
/api/philosophy and /api/docs.

Path validation gates the network: only /^\d{4}$/ session dirs and
the full SESSION-NNNN.M-TRANSCRIPT-...md filename shape pass to the
upstream. Legacy flat-root names (e.g. SESSION-A-TRANSCRIPT.md) and
path-traversal attempts are rejected 400 before any fetch.

18 new tests cover the happy paths, 404 empty-states, 502 upstream
errors, path-validation rejections, and the cache-hit-within-TTL
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:07:01 -07:00
Ben Stull ac3513a686 Merge: CONTRIBUTING.md + SPEC.md §21 analytics chapter (Session 0013.1)
Docs-only merge from feature/contributing-and-spec-analytics @ 213f686:
- CONTRIBUTING.md (407 lines, new) — how to contribute to rfc-app:
  branch naming, CHANGELOG strict-descending, RFC 2119 upgrade-steps,
  SPEC.md §19.2 candidate hygiene, test-coverage expectations,
  analytics instrumentation checklist (rides #21 Part B), operator-
  only gestures (including the 'never ask for secret bytes' rule).
  Cites Sessions E/I/K/L (= 0005.0/0009.0/0011.0/0012.0) as worked
  examples.
- SPEC.md §21 (469 lines, new) — Analytics instrumentation and
  identity. Ten subsections covering event taxonomy, required prop
  families, autocapture-friendly DOM patterns, replay masking,
  consent-gate contract, identity lifecycle (Part C), set vs
  setOnce taxonomy, cohort implications, overlay-binding rule for
  VITE_AMPLITUDE_API_KEY, §19.2 candidates from this chapter.

No code change, no version bump. Authored by subsession M.1
(= 0013.1) of Session M (= 0013.0); reviewed in Session 0014.0;
merged in Session 0014.0 per operator delegation.
2026-05-28 08:29:41 -07:00
Ben Stull 31913b1e53 Merge feature/v0.18.0-email-webhook-hygiene
v0.18.0 — email + webhook hygiene per the proposal at
~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md. All five
slices ship; 295 tests passing.
2026-05-28 07:39:39 -07:00
Ben Stull 0562d53f86 Release 0.18.0: email + webhook hygiene (roadmap items #18 + #20)
VERSION + frontend/package.json -> 0.18.0. CHANGELOG entry with
the binding Upgrade-steps block per SPEC.md §20.4.

This release lands all five slices of the v0.18.0 proposal at
~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md:

  Slice 1: build_envelope helper + unit tests.
  Slice 2: migrate send paths; add POST /api/email/unsubscribe
           for RFC 8058 one-click.
  Slice 3: mandatory GITEA_WEBHOOK_SECRET (+ dev-bypass) +
           unknown-repo logging.
  Slice 4: outbound_emails audit table + admin endpoint.
  Slice 5: bounce correlation via Message-ID.

Full suite: 295 passed (was 252 pre-release).

Upgrade-steps (RFC 2119) block in CHANGELOG covers:
  * MUST: GITEA_WEBHOOK_SECRET non-empty at startup.
  * MAY: RFC_APP_INSECURE_WEBHOOKS=1 for local dev only.
  * MAY: EMAIL_UNSUBSCRIBE_MAILTO to route opt-out courtesy mail
         to a different mailbox than EMAIL_FROM.
  * MUST: apply migration 020_outbound_emails.sql (auto-applied
          on next start; no operator action).
  * MUST: rebuild frontend + restart backend.
  * SHOULD: run mail-tester.com probe post-upgrade.
2026-05-28 07:39:33 -07:00
Ben Stull 4666c4abe7 v0.18.0 Slice 5: bounce correlation hook
The `POST /api/webhooks/email-bounce` body accepts a new optional
`message_id` field. When supplied, the handler looks up the
matching row in `outbound_emails` and stamps status='bounced' +
appends "bounce (<kind>)" to the error column. The response
gains a `correlated_id` field (nullable: null when no
message_id was provided or no row matched).

Hard-bounce -> `email_opt_out_all = 1` still fires regardless
(the v1 contract from api_notifications.py:486-498); Slice 5
just adds the per-message audit trail on top.

Providers that don't surface Message-ID get the legacy v1
behavior — match by email, flip the global opt-out, leave
correlated_id null. Providers that replay an old bounce with a
message_id the framework no longer has log an INFO line but
still 200 (the bounce path can't refuse just because a row was
pruned).

Test update: `test_bounce_webhook_refuses_unsigned_when_secret_configured`
in test_e2e_smoke.py was asserting on the exact response shape
{ok, matched}; v0.18.0 adds `correlated_id`. The test now asserts
on the new shape; documented in CHANGELOG.

4 new tests covering: bounce with message_id stamps the row;
unknown message_id is logged + does not crash; bounce without
message_id still flips opt-out (backward compat); bounced rows
surface in the admin endpoint with `?status=bounced`.

Full suite: 295 passed.
2026-05-28 07:36:02 -07:00
Ben Stull 281a844513 v0.18.0 Slice 4: outbound_emails audit table + admin endpoint
Per the v0.18.0 email + webhook hygiene proposal §3:

  * `backend/migrations/020_outbound_emails.sql` — new table
    capturing every send attempt: to_address, from_address,
    subject, kind, sent_at, status ('sent' | 'failed' | 'deferred'
    | 'bounced'), error, notification_id (FK), message_id (for
    Slice 5 bounce correlation).
  * `email.record_outbound()` — best-effort write helper every
    send path calls. Status='sent' on SMTP success, 'failed' on
    exception (with class + message in `error`), 'deferred' on
    the dev-fallback path where SMTP_HOST is unset (the send
    didn't happen but the row records the attempt).
  * `email._deliver` (watcher notifications + bundles), `digest.py`,
    `email_otc.py`, `email_invite.py` — every send path now records.
  * `GET /api/admin/outbound-emails` — admin-only listing,
    filterable by kind / status / to_address. Answers "did this
    person ever get their invite?" without grepping VM logs. No
    admin UI in v0.18.0; operator queries via curl + jq for now.

7 new integration tests covering: OTC / invite / notification all
write rows with matching Message-ID; admin endpoint lists,
filters by kind, filters by to_address (case-insensitive),
refuses non-admins.

Full suite: 291 passed.
2026-05-28 07:32:31 -07:00
Ben Stull d3daa97264 v0.18.0 Slice 3: webhook tightening — mandatory secret + dev-bypass
`GITEA_WEBHOOK_SECRET` is now mandatory at startup. The framework
refuses to load_config() when the env var is empty unless the
operator opts into the dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`.
This is the v0.18.0 startup-loud-failure shape — the pre-v0.18.0
"silently accept unsigned POSTs when the secret is empty" path is
the bug the proposal targets.

`webhooks.receive`:
  * Defense in depth: refuses 500 if the secret is empty at request
    time and the dev-bypass is not set (catches the case where
    something mutates env after startup).
  * Logs a loud warning every time a webhook lands under the
    bypass — so a misconfigured production deployment shows up in
    the logs even if the operator missed the warning at boot.
  * Adds an INFO log at the unknown-repo branch (previously
    silently 200-OK'd a hook on a fork or a stale Gitea binding).

`tmp_env` fixture binds a fake secret so the existing 277 tests
boot cleanly; the new test_webhooks_vertical.py exercises both
the production-secret path (valid signature, invalid signature,
missing signature) and the dev-bypass path (config loads with
empty secret when bypass set, refuses without it).

7 new tests; full suite: 284 passed.
2026-05-28 07:27:23 -07:00
Ben Stull e9fdc478f6 v0.18.0 Slice 2: migrate send paths to build_envelope + POST unsubscribe
Five send sites now construct their envelopes through
`email_envelope.build_envelope` instead of building `EmailMessage`
ad hoc:

  * `email_otc.py` — OTC mail (no List-Unsubscribe per the
    proposal's tradeoff: the recipient explicitly requested the
    code, so a list semantic would be wrong).
  * `email_invite.py` — admin/per-RFC invite mail (mailto:-only
    List-Unsubscribe — the invitee isn't a user yet, so no
    per-user opt-out URL exists).
  * `email._deliver` — watcher notifications (full one-click
    unsubscribe: mailto + signed URL + List-Unsubscribe-Post per
    RFC 8058, required by Gmail/Yahoo).
  * `email._send_bundle` — the "while you were away" bundle,
    one-click to the new `all` synthetic category (which lands
    `email_opt_out_all = 1` because the bundle spans multiple
    per-category flags).
  * `digest.py` — same as the bundle: bulk-adjacent, one-click to
    `all`.

`api_notifications.py` gains the POST `/api/email/unsubscribe`
endpoint (the matching receiver for `List-Unsubscribe-Post:
List-Unsubscribe=One-Click`) and accepts the `all` category in
both GET and POST handlers.

`EmailConfig` adds `unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`,
default = `EMAIL_FROM`) so deployments can route unsubscribe
courtesy mail to a humans-monitored mailbox distinct from the
no-reply sender.

The `_SENT` test buffer now also carries `envelope["message"]`
(the `EmailMessage` itself) so new tests can assert on headers
directly. Legacy `to`/`from`/`subject`/`body` keys remain for
backward compatibility.

10 new integration tests across test_otc_vertical /
test_admin_create_user_invite_vertical / test_notifications_vertical
covering: OTC has no List-Unsubscribe; invite has mailto: only;
notification has full one-click; POST one-click flips the
category; `all` category sets global opt-out via both GET and
POST.

Full suite: 277 passed.
2026-05-28 07:24:03 -07:00
Ben Stull 92059f319e v0.18.0 Slice 1: build_envelope helper + unit tests
Per the v0.18.0 email + webhook hygiene proposal
(~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md §1), this is the
shared envelope builder every send path will call in Slice 2. No
call-site changes yet — Slice 2 migrates email_otc / email_invite /
email._deliver / email._send_bundle to use it.

The helper centralizes the deliverability-critical headers (Date,
Message-ID, Auto-Submitted) and exposes per-kind unsubscribe
semantics (none for OTC, mailto: for invites, full one-click for
watcher notifications) as explicit kwargs rather than buried in
each call site.

15 new unit tests covering: always-present headers, Auto-Submitted
toggle, the three List-Unsubscribe shapes, plain-only vs
multipart/alternative body. Full suite: 267 passed (was 252).
2026-05-28 07:15:06 -07:00
259 changed files with 45584 additions and 2546 deletions
+15
View File
@@ -0,0 +1,15 @@
# Keep the preview build context lean + reproducible.
.git
.gitea
**/__pycache__/
**/*.pyc
backend/.venv/
backend/data/
frontend/node_modules/
frontend/dist/
e2e/
mockups/
docs/
*.md
!VERSION
.pytest_cache/
+1
View File
@@ -26,3 +26,4 @@ data/
# Claude Code (per-machine settings only; shared config under .claude/ is committed)
.claude/settings.local.json
.superpowers/
+2121
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,3 +1,5 @@
@~/.claude/wiggleverse.md
# Working in rfc-app
This is the framework — the software that hosts an RFC standardization
+105 -12
View File
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
## Signing in
While the framework is in private beta, only invited email addresses
can complete sign-in. If your email is on the allowlist, the
"Sign in" button in the header completes the flow and lands you on
the catalog with full read and write access. If your email is not on
the allowlist, you'll be sent to a short "pending" page explaining
the gate.
Anyone can start the sign-in flow with their own email address — there
is no invite-only allowlist. Sign-in is passwordless:
Once you have an account, you're a **contributor** by default — the
role that grants every write affordance the app exposes, scoped by
the per-RFC and per-branch rules described below.
1. **Enter your email.** If the deployment has human verification
enabled (a Cloudflare Turnstile challenge), you complete it here.
2. **Enter the one-time code.** The app emails you a short numeric
code; entering it signs you in. Codes expire after a few minutes,
and repeated wrong entries briefly lock the email.
3. **Set a passcode (optional).** After your first code sign-in you
can set a passcode. On later visits you sign in with email +
passcode, with the one-time code as the forgot-passcode fallback.
4. **Trust this device (optional).** You can mark a device trusted for
30 days to skip the code/passcode step on it. Trusted devices are
listed in your settings and can be revoked individually or all at
once.
### Getting write access
Signing in gives you an account, but write access is gated. The first
time you sign in you're asked for your first name, last name, and a
short note on why you'd like access; you then land on a "request in
review" page. While your account is **pending**, you can read
everything an anonymous visitor can but cannot write — no chat,
propose, branch, PR, or discussion post. Once an admin **grants** your
account you become a **contributor**, the role that carries every
write affordance the app exposes, scoped by the per-RFC and per-branch
rules described below.
An admin can also create your account ahead of time and email you an
invite link. Clicking it claims the account and signs you in with the
role the admin assigned, skipping the one-time-code step.
---
## Proposing a new RFC
A new RFC begins as a proposal. The "+ Propose new RFC" button at
the bottom of the catalog opens a small modal that collects four
the bottom of the catalog opens a small modal that collects five
things:
- **Title.** The word, concept, or topic this RFC would define.
@@ -65,8 +86,13 @@ things:
inline.
- **Pitch.** One or two paragraphs answering *why this RFC is
needed*. This becomes the body of the entry.
- **Tags.** Optional. The AI suggests tags from the pitch; you can
accept, dismiss, or type your own.
- **Use case.** Optional. *What will you be using this RFC for?*
the concrete application driving the proposal, as distinct from the
abstract case for it. Leaving it blank is fine.
- **Tags.** Optional. If the deployment has AI tag suggestion
enabled, suggested tags appear as you fill the form (with an inline
note that the text you've entered is sent to the model that
generates them); you can accept, dismiss, or type your own.
Submitting the modal does one concrete thing: it opens a pull
request against the framework's meta repository, adding one new
@@ -167,6 +193,51 @@ either requires a contributor account.
---
## Invitations, cross-references, and contribution requests
Three connected surfaces help the right people find and join the
right RFC.
### Owner invitations
An RFC's owner (or an app-wide admin or owner) can invite a specific
person to that RFC from the "Invitations" control in the RFC header.
The invite names an email and a role for *this RFC*:
- **contributor** — can open PRs and join the discussion;
- **discussant** — can join the discussion only.
The invitee gets an email with an accept link; accepting adds them as
a collaborator on that RFC. The invitations panel lists every invite
with its status (pending / accepted / expired / revoked); pending
invites can be revoked. An invitation is per-RFC — it does not change
the invitee's app-wide role, and it cannot lift the pending gate: the
invitee still needs a granted account to write.
### RFC cross-links in PRs and comments
When a PR description or a comment mentions an existing active RFC —
by its ID, its multi-word title, or its slug — the framework renders
that mention as a link to the RFC. The matching is conservative by
design (single common words are never auto-linked), and the links are
computed at read time, so nothing is rewritten in what you typed.
### "Create" and "ask to contribute" offers
The same scan surfaces two affordances inline:
- If a term looks like it should have an RFC but none exists yet, a
reader who has create rights sees a **"create RFC for '<term>'"**
link that opens the propose modal with the title pre-filled.
- If a term matches a *pending* RFC (a super-draft someone already
owns), a signed-in reader sees an **"ask to contribute"** offer
naming the owner. It opens a short request form — who you are, why
you're asking, and optionally what you'd use the RFC for. The
request lands in the owner's inbox; the owner can **accept** (which
sends you an owner invitation) or **decline** (which notifies you).
---
## Working on a branch
Contribute mode flips one branch into edit-enabled. The centre
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
entirely. The framework names a single "owner zero" at
bootstrap.
Between anonymous and contributor sits one transient state:
**pending**. A freshly signed-in account that hasn't been granted
access yet (see [Signing in](#signing-in)) reads everything an
anonymous visitor can, but no write affordance unlocks until an admin
grants it. Granting promotes the account to contributor; an admin can
also revoke a granted account back to a no-write state. These
transitions are recorded in the `permission_events` log.
The practical difference between admin and owner is narrow but
load-bearing: admin is the operational tier — it does the day-to-
day moderation and stewardship work; owner is the tier that
@@ -594,6 +673,20 @@ review.
---
## Privacy and cookies
A consent banner appears on your first visit and lets you choose
which cookie categories to allow — essential always, with analytics
and other categories opt-in. The choice is remembered and can be
changed any time from the privacy/cookies controls in settings.
Analytics only load if you opt in: the framework defers the analytics
SDK behind your consent, so declining means it is never initialized.
The `/privacy` and `/cookies` pages describe what's collected and
why; a deployment can point those pages at its own fuller policy.
---
## Where to learn more
- The framework's *why* lives in [the philosophy
+62
View File
@@ -0,0 +1,62 @@
# rfc-app container image — the Cloud Run keystone for per-PR preview
# environments (flotilla SPEC §15). NOT used by production: prod still deploys
# via the pin-based on-VM gesture (flotilla §8). This image exists so flotilla
# `preview up --pr=N` can build a PR's tree and run it as an ephemeral,
# scale-to-zero Cloud Run service with a SEEDED SYNTHETIC database and ZERO real
# secrets (test-secret env only — flotilla §15 / §3 invariant 1).
#
# Single container, single port: nginx serves the built SPA on $PORT (Cloud Run
# injects it) and reverse-proxies /api/ + /auth/ to a single-process uvicorn on
# 127.0.0.1:8000 — mirroring the prod nginx + systemd split
# (deploy/nginx/ohm.wiggleverse.org.conf, deploy/systemd/rfc-app.service), so a
# preview behaves like prod minus the secrets. Single uvicorn process + single
# SQLite file, per §4.2 (never scale workers).
#
# Build context is the repo root: docker build -t <image> .
# ---- stage 1: build the Vite SPA -------------------------------------------
FROM node:20-slim AS web
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# VITE_* values are baked into the bundle at build time (intentionally public —
# §8 phase-5 note). VITE_APP_NAME is REQUIRED by vite.config.js (the framework
# ships no default — each deployment names itself); previews self-name. Turnstile
# uses Cloudflare's always-pass test SITE key so the widget renders + auto-passes.
ARG VITE_APP_NAME="RFC App (preview)"
ARG VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
ARG VITE_AMPLITUDE_API_KEY=
RUN VITE_APP_NAME="$VITE_APP_NAME" \
VITE_TURNSTILE_SITE_KEY="$VITE_TURNSTILE_SITE_KEY" \
VITE_AMPLITUDE_API_KEY="$VITE_AMPLITUDE_API_KEY" \
npm run build
# ---- stage 2: runtime (backend + nginx) ------------------------------------
FROM python:3.12-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx gettext-base sqlite3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/rfc-app
# Backend deps first for layer caching.
COPY backend/requirements.txt backend/requirements.txt
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ backend/
COPY deploy/preview/ deploy/preview/
# health.py reads VERSION at parents[2] (== /opt/rfc-app/VERSION).
COPY VERSION ./
COPY --from=web /app/frontend/dist/ frontend/dist/
RUN chmod +x deploy/preview/entrypoint.sh
# Cloud Run injects $PORT (default 8080); the entrypoint renders nginx against
# it. The synthetic preview DB lives on the container's ephemeral filesystem and
# dies with the instance (zero create/seed/drop lifecycle — §15).
ENV PORT=8080 \
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
EXPOSE 8080
ENTRYPOINT ["deploy/preview/entrypoint.sh"]
+33
View File
@@ -0,0 +1,33 @@
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install e2e-fresh
# Two-phase: run the Gitea seed to completion FIRST so it writes the bot token /
# OAuth creds into generated/.env.tier1.generated, THEN create the backend/web —
# compose snapshots env_file at container-create time, so the backend must be
# created after the seed has populated it. The touch seeds an empty placeholder
# for compose's up-front env_file existence check on a clean checkout.
tier1-up:
touch testing/generated/.env.tier1.generated
docker compose -f testing/docker-compose.yml up --build -d gitea-seed
docker compose -f testing/docker-compose.yml wait gitea-seed
docker compose -f testing/docker-compose.yml up --build -d
tier1-down:
touch testing/generated/.env.tier1.generated
docker compose -f testing/docker-compose.yml down -v
tier1-logs:
docker compose -f testing/docker-compose.yml logs -f
fe-unit:
cd frontend && npm run test:run
e2e-install:
cd e2e && npm ci && npx playwright install chromium
e2e:
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
# Canonical run: the metadata specs mutate the seeded corpus (edit/bulk write
# real commits), so they assume a freshly-seeded stack. This brings the stack
# down, back up (re-seeds), and runs the suite once — the shape CI uses.
e2e-fresh: tier1-down tier1-up e2e
+907 -185
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
0.17.0
0.52.2
+36 -3
View File
@@ -9,10 +9,18 @@ GITEA_URL=http://localhost:3000
GITEA_BOT_USER=rfc-bot
GITEA_BOT_TOKEN=
# The Gitea org or user that owns the meta repo and every RFC repo
# the bot will create on graduation.
# The Gitea org or user that owns every RFC repo the bot will create on
# graduation.
GITEA_ORG=wiggleverse
META_REPO=meta
# §22.2 — the project registry repo (REQUIRED). The framework reads
# `projects.yaml` at its root to learn which projects exist. The repo name is
# the deployment's choice; the app fails to start if this is unset.
REGISTRY_REPO=registry
# §22.13 — optional id for the bootstrap/default project. Reserved for the
# Plan B re-stamp; leave unset in Plan A (the default project id stays
# `default`). When set, it must match an `id` in projects.yaml.
# DEFAULT_PROJECT_ID=ohm
# --- OAuth (Gitea) ---
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
@@ -38,10 +46,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
# Comma-separated list of provider keys to enable. Per the §19.2
# per-RFC-model topic, this is app-wide until that topic lands.
ENABLED_MODELS=claude
# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions
# (roadmap #27) — that surface always uses Claude Haiku for cost,
# independent of ENABLED_MODELS. With no key set, tag suggestions are
# simply unavailable (the modal hides the row); the rest of the app is
# unaffected.
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
# --- Tag suggestions (§9.1 / roadmap #27) ---
# Per-user rate limit on the suggest-tags endpoint (cost backstop; the
# modal debounces and the endpoint is contributor-gated). Optional —
# these defaults apply when unset.
TAG_SUGGEST_RATE_MAX=30
TAG_SUGGEST_RATE_WINDOW_SECONDS=60
# --- Email (§15.4) ---
# Leave SMTP_HOST unset to use the stdout fallback — the integration
# tests rely on it, and a dev environment without a real SMTP provider
@@ -110,3 +130,16 @@ CLOUDFLARE_TURNSTILE_SECRET=
# config drift surfaces as a loud 500 rather than a silent abuse-
# defense disablement.
TURNSTILE_REQUIRED=false
# --- Deployed-environment E2E test auth (v0.52.0) ---
# DANGER: NEVER set these on a production deployment. Together they
# enable `POST /auth/test/login`, which mints an authenticated OWNER
# session for the one configured email without any OTC/email round trip
# — it exists only to run the Playwright E2E suite against a deployed
# pre-prod (PPE) host that has no Mailpit sink. The route is fail-closed:
# it returns 404 unless BOTH vars below are set, requires the caller to
# present E2E_TEST_AUTH_SECRET in the `X-Test-Auth-Secret` header
# (constant-time compare), and only ever mints the single configured
# email (any other → 403). Leave BOTH unset everywhere except PPE.
# E2E_TEST_AUTH_EMAIL=e2e-owner@example.test
# E2E_TEST_AUTH_SECRET= # a Secret Manager ref on real deployments; never a literal here
+731 -26
View File
@@ -15,27 +15,40 @@ import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse, Response
from pydantic import BaseModel, Field
from . import (
api_admin,
api_branches,
api_collections,
api_contributions,
api_deployment,
api_discussion,
api_graduation,
api_invitations,
api_join_requests,
api_memberships,
api_metadata,
api_notifications,
api_prs,
auth,
collections as collections_mod,
projects as projects_mod,
db,
device_trust as device_trust_mod,
docs as docs_mod,
docs_sessions,
docs_specs,
entry as entry_mod,
cache,
facets,
funder,
health,
notify,
philosophy,
providers as providers_mod,
tag_suggest,
)
from .bot import Bot
from .config import Config
@@ -48,6 +61,22 @@ class ProposeBody(BaseModel):
slug: str = Field(min_length=1, max_length=80)
pitch: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
# Roadmap #26: optional "What will you be using this RFC for?" — the
# concrete ground-truth use case, distinct from the `pitch`'s abstract
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
# generous cap matching the pitch's free-text body bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class SuggestTagsBody(BaseModel):
# Roadmap #27: the partial propose-RFC draft, sent as the user types
# (debounced on the frontend). All fields optional — suggestions
# refine as the draft fills in. `pitch` is the "why is this needed"
# rationale; `use_case` is the #26 optional ground-truth field.
# Bounds mirror the propose body's free-text caps.
title: str = Field(default="", max_length=200)
pitch: str = Field(default="", max_length=8000)
use_case: str = Field(default="", max_length=8000)
class DeclineBody(BaseModel):
@@ -59,6 +88,18 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class LastStateBody(BaseModel):
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
# `route` is a frontend pathname the user was last on (bounded so a
# hostile client can't stuff arbitrary blobs through). `state` is an
# optional bag of *light* view state (scroll anchors, open tab,
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
# the frontend only ever sends ephemeral view state, and the column
# comment in migration 022 + SPEC §6.2 are the binding contract.
route: str = Field(min_length=1, max_length=2048)
state: dict[str, Any] | None = None
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
@@ -90,6 +131,8 @@ def make_router(
router.include_router(api_prs.make_router(config, gitea, bot, providers))
# Slice 5: §13 graduation + §13.1 claim.
router.include_router(api_graduation.make_router(config, gitea, bot))
# §22.4a SLICE-4/5: entry metadata edit + Owner-gated collection migrate.
router.include_router(api_metadata.make_router(config, gitea, bot))
# Slice 6: §15 notifications surface (inbox, watches, prefs,
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
router.include_router(api_notifications.make_router(config))
@@ -109,6 +152,21 @@ def make_router(
# invited users keep read access but cannot write (v0.6.0
# contract extended to per-RFC scope).
router.include_router(api_invitations.make_router())
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
# on accept; lands the request + owner notifications via §15 notify.
router.include_router(api_contributions.make_router())
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
# VITE_APP_NAME) + the old-URL 308 redirects.
router.include_router(api_deployment.make_router(config, gitea, bot))
router.include_router(api_collections.make_router(config, gitea, bot))
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
# {owner, contributor} at project/collection scope to existing accounts.
router.include_router(api_memberships.make_router())
# §22.8 S6: request-to-join + the cross-collection inbox — a user asks into a
# scope (naming a role); the scope's Owners across the subtree accept (writing
# the membership row) or decline.
router.include_router(api_join_requests.make_router())
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
@@ -143,6 +201,177 @@ def make_router(
payload = docs_mod.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# v0.19.0 / roadmap item #30 — /api/docs/sessions/*
#
# The framework mediates reads against the public
# `wiggleverse/ohm-session-history` gitea repo so the rendered
# `/docs/sessions/*` surface inherits the same chrome as the
# /docs/user-guide route and doesn't require a cross-origin
# gesture from the frontend. See backend/app/docs_sessions.py
# for the cache shape and env knobs.
#
# The route mapping for the three `status` values returned by
# the fetchers:
#
# "ok" → HTTP 200, payload as documented per endpoint
# "404" → HTTP 200/404 depending on the endpoint (the
# manifest's empty state is 200 + {} so the
# frontend can short-circuit without an error
# banner; transcripts/about return 404 so the
# frontend can render its own empty-state)
# "error" → HTTP 502, {"error": ..., "detail": ...} so the
# frontend retry surface reads as "couldn't reach
# the session-history repo" rather than as a
# generic 5xx.
# ---------------------------------------------------------------
@router.get("/api/docs/sessions/manifest")
async def get_sessions_manifest() -> dict[str, Any]:
result = await docs_sessions.fetch_manifest()
if result["status"] == "ok":
return result["manifest"]
if result["status"] == "404":
# Empty-state contract: render no session rows in the
# flyout but don't show an error banner. The frontend
# treats `{}` as "no sessions published yet".
return {}
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
@router.get("/api/docs/sessions/about")
async def get_sessions_about() -> Response:
result = await docs_sessions.fetch_about()
if result["status"] == "ok":
return PlainTextResponse(
content=result["body"],
media_type="text/markdown; charset=utf-8",
)
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="session-history README not yet published",
)
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
@router.get("/api/docs/sessions/{nnnn}/index")
async def get_sessions_index(nnnn: str) -> dict[str, Any]:
if not docs_sessions._is_valid_session_dir(nnnn):
# 400 over 404: the request itself is malformed (the
# session directory name doesn't match `^\d{4}$`),
# distinct from "no such session published yet".
raise HTTPException(status_code=400, detail="invalid session directory")
result = await docs_sessions.fetch_session_index(nnnn)
if result["status"] == "ok":
return {"files": result["files"]}
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="no transcripts published for this session",
)
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
@router.get("/api/docs/sessions/{nnnn}/{filename}")
async def get_sessions_transcript(nnnn: str, filename: str) -> Response:
# Path-shape validation before any network — refuses anything
# that would resolve outside the `NNNN/SESSION-...md` layout
# (e.g. legacy `SESSION-A-TRANSCRIPT.md` at the repo root,
# `../etc/passwd`, or any non-numeric session dir).
if not docs_sessions._is_valid_session_dir(nnnn):
raise HTTPException(status_code=400, detail="invalid session directory")
if not docs_sessions._is_valid_transcript_filename(filename):
raise HTTPException(status_code=400, detail="invalid transcript filename")
result = await docs_sessions.fetch_transcript(nnnn, filename)
if result["status"] == "ok":
return PlainTextResponse(
content=result["body"],
media_type="text/markdown; charset=utf-8",
)
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="transcript not found",
)
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
# ---------------------------------------------------------------
# v0.20.0 — /api/docs/specs/*
#
# Sibling of the v0.19.0 docs-sessions surface: the framework
# mediates a fetch against the public gitea raw URL for each
# configured spec so the rendered `/docs/specs/*` route inherits
# the same chrome (and the same auth-less reach) as the user
# guide and the session-history browser. See
# backend/app/docs_specs.py for the manifest shape, the env
# knobs, and the cache.
#
# Status-to-HTTP mapping mirrors docs_sessions:
# "ok" → HTTP 200, payload as documented per endpoint
# "404" → HTTP 200 / 404 (manifest 404 doesn't apply here —
# the manifest is derived from env, never 404s; spec
# 404 returns HTTP 404 so the frontend can render
# "spec not yet published / unknown name")
# "error" → HTTP 502
# ---------------------------------------------------------------
@router.get("/api/docs/specs/manifest")
async def get_specs_manifest() -> dict[str, Any]:
# The manifest is derived from env (`OHM_DOCS_SPECS`) and
# never fails — a malformed value falls back to the framework
# default at parse time. So this endpoint always returns 200
# + a list (the framework default is non-empty).
result = docs_specs.fetch_specs_manifest()
return {"specs": result["specs"]}
@router.get("/api/docs/specs/{name}")
async def get_spec(name: str) -> Response:
# Slug validation before any network — refuses `..`, `/`,
# uppercase, whitespace, etc. Same defense-in-depth posture
# as the docs-sessions transcript endpoint.
if not docs_specs._is_valid_name(name):
raise HTTPException(status_code=400, detail="invalid spec name")
result = await docs_specs.fetch_spec(name)
if result["status"] == "ok":
return PlainTextResponse(
content=result["body"],
media_type="text/markdown; charset=utf-8",
)
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="spec not found",
)
raise HTTPException(
status_code=502,
detail={
"error": "specs fetch failed",
"detail": result.get("detail", "unknown"),
},
)
# ---------------------------------------------------------------
# Auth surface — reads role from our users table per §6.
# ---------------------------------------------------------------
@@ -175,6 +404,28 @@ def make_router(
)
has_passcode = bool(row and row["passcode_hash"])
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
# v0.23.0 / item #29: fold the sign-in-resume state onto the
# same round-trip the frontend already makes on boot. When
# resume is disabled (resume_enabled = 0) we hand back a null
# route so the client never redirects; the stored row stays put
# so re-enabling later resumes the last-known route.
state_row = db.conn().execute(
"SELECT last_route, last_route_state, resume_enabled "
"FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
last_route = (
state_row["last_route"]
if (state_row and resume_enabled)
else None
)
last_route_state = None
if state_row and resume_enabled and state_row["last_route_state"]:
try:
last_route_state = json.loads(state_row["last_route_state"])
except (ValueError, TypeError):
last_route_state = None
return {
"authenticated": True,
"user": {
@@ -191,6 +442,10 @@ def make_router(
"needs_profile": needs_profile,
"has_passcode": has_passcode,
"passcode_set_at": passcode_set_at,
# v0.23.0 / item #29 — sign-in state resume.
"resume_enabled": resume_enabled,
"last_route": last_route,
"last_route_state": last_route_state,
},
}
@@ -260,6 +515,52 @@ def make_router(
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
return {"ok": True}
# ---------------------------------------------------------------
# v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
# resume. The frontend debounce-posts the user's current route +
# a small bag of light view state here on every route change; the
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
#
# Per-user (NOT per-device) — one row per user, keyed on user_id.
# `resume_enabled` is the opt-out flag (default on); when it's 0
# this endpoint no-ops so a user who turned resume off doesn't keep
# silently rewriting their stored route. PRIVACY: the body carries
# route + light state ONLY, never draft-buffer contents (migration
# 022 column comment + SPEC §6.2 are the binding contract).
#
# `require_user` (not `require_contributor`) — a pending/granted
# distinction is irrelevant for "remember where I was", and a
# pending user navigating read-only surfaces should still resume.
# Anonymous callers get the 401 `require_user` raises.
# ---------------------------------------------------------------
@router.put("/api/me/last-state")
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
# Respect the opt-out: if a row already exists with resume
# disabled, leave it untouched and report the no-op. A first-
# ever POST (no row yet) defaults to enabled and stores.
existing = db.conn().execute(
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
if existing is not None and not existing["resume_enabled"]:
return {"ok": True, "stored": False}
state_json = json.dumps(body.state) if body.state is not None else None
db.conn().execute(
"""
INSERT INTO user_session_state
(user_id, last_route, last_route_state, last_updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
last_route = excluded.last_route,
last_route_state = excluded.last_route_state,
last_updated_at = excluded.last_updated_at
""",
(user.user_id, body.route, state_json),
)
return {"ok": True, "stored": True}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
@@ -327,25 +628,41 @@ def make_router(
# ---------------------------------------------------------------
@router.get("/api/rfcs")
async def list_rfcs(request: Request) -> dict[str, Any]:
async def list_rfcs(request: Request, unreviewed: str | None = None) -> dict[str, Any]:
"""§7's left pane data.
The chip-filter / sort / search combinatorics live on the
client the server returns the full set and lets the chips
narrow it. The set is small (hundreds, not thousands) for the
foreseeable future, so paginating here would buy nothing.
§22.4c: pass `?unreviewed=true` to narrow to active entries
still awaiting owner review.
"""
viewer = auth.current_user(request)
viewer_id = viewer.user_id if viewer else None
# §22.5: a gated project's entries never surface in a non-member's
# catalog. For the single public default project this is the full set.
visible = auth.visible_project_ids(viewer)
if not visible:
return {"items": []}
placeholders = ",".join("?" for _ in visible)
params = list(visible)
unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
rows = db.conn().execute(
"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
"""
f"""
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
r.owners_json, r.arbiters_json, r.tags_json,
r.metadata_malformed,
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
WHERE r.state IN ('super-draft', 'active')
AND c.project_id IN ({placeholders}){unreviewed_clause}
ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
""",
params,
).fetchall()
starred = set()
@@ -372,32 +689,280 @@ def make_router(
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
"starred_by_me": r["slug"] in starred,
"has_open_prs": False, # wired in Slice 2 when per-RFC repos exist
"metadata_malformed": bool(r["metadata_malformed"]),
}
)
return {"items": items}
@router.get("/api/rfcs/{slug}")
async def get_rfc(slug: str) -> dict[str, Any]:
async def get_rfc(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
return _serialize_rfc(row)
viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members. Recover the project via the entry's collection.
auth.require_project_readable(viewer, auth.project_of_rfc(slug))
# §13.7: a retired entry is removed from every browsing surface. The
# sole exception is a site owner, so the un-retire affordance has
# somewhere to live; everyone else gets a plain 404.
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
raise HTTPException(404, "Not found")
payload = _serialize_rfc(row)
# Roadmap #26: surface the optional propose-time use case on the
# RFC view. The idea PR closes on merge, but the canonical row in
# `proposed_use_cases` persists; look it up by slug (the latest
# 'rfc'-scope row for this slug). NULL == "left blank".
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ?
ORDER BY id DESC LIMIT 1
""",
(slug,),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
# §22.4a SLICE-4: contributor+ on the entry's collection may edit metadata.
payload["can_edit_meta"] = bool(
auth.can_contribute_in_collection(viewer, auth.collection_of_rfc(slug)))
return payload
# ---------------------------------------------------------------
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
# scoped to one project, identified by its own slug namespace
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
# default-project compat path; the frontend reads these scoped routes so a
# second project's corpus renders under /p/<id>/.
# ---------------------------------------------------------------
def _require_collection_in_project(collection_id: str, project_id: str) -> None:
# §22 S2: a collection-scoped route 404s when the collection does not
# belong to the project in the path (shape matches an unknown id).
if collections_mod.project_of_collection(collection_id) != project_id:
raise HTTPException(404, "Not found")
def _list_rfcs_for_collection(
collection_id: str, viewer, unreviewed: str | None,
query_params=None,
) -> dict[str, Any]:
viewer_id = viewer.user_id if viewer else None
# §22.4a SLICE-3: the collection's declared field schema drives the facet
# set (None when undeclared → no facets, INV-5).
col = collections_mod.get_collection(collection_id)
fields_schema = (col or {}).get("fields") or None
# Parse + validate filter selections from the query string. Unknown
# field → 400 (§6.4). `unreviewed` keeps its existing meaning; an
# empty-valued selection is ignored, not an error (plan decision 6).
selections: dict[str, set[str]] = {}
only_malformed = False
if query_params is not None:
allowed = facets.allowed_filter_keys(fields_schema)
facet_names = {n for n, _ in facets.facet_fields(fields_schema)}
for key in query_params.keys():
if key not in allowed:
raise HTTPException(400, f"unknown filter field {key!r}")
if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"):
only_malformed = True
for name in facet_names:
vals = {v for v in query_params.getlist(name) if v != ""}
if vals:
selections[name] = vals
unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
rows = db.conn().execute(
f"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json, metadata_malformed,
meta_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
AND collection_id = ?{unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
""",
(collection_id,),
).fetchall()
starred = set()
if viewer_id is not None:
starred = {
r["rfc_slug"]
for r in db.conn().execute(
"SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
(viewer_id, collection_id),
)
}
# Build entry dicts the facet helper understands (state + malformed +
# parsed meta), preserving SQL order.
entries = []
for r in rows:
try:
meta = json.loads(r["meta_json"]) if r["meta_json"] else {}
except (TypeError, ValueError):
meta = {}
entries.append({
"slug": r["slug"],
"title": r["title"],
"state": r["state"],
"id": r["rfc_id"],
"repo": r["repo"],
"owners": json.loads(r["owners_json"] or "[]"),
"arbiters": json.loads(r["arbiters_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
"starred_by_me": r["slug"] in starred,
"has_open_prs": False,
"metadata_malformed": bool(r["metadata_malformed"]),
"meta": meta,
})
filtered, facet_counts = facets.filter_and_count(
entries, fields_schema, selections, only_malformed=only_malformed
)
return {"items": filtered, "facets": facet_counts}
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
(collection_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
raise HTTPException(404, "Not found")
payload = _serialize_rfc(row)
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
ORDER BY id DESC LIMIT 1
""",
(slug, collection_id),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
# §22.4a SLICE-4: contributor+ on the collection may edit metadata (INV-4).
payload["can_edit_meta"] = bool(
auth.can_contribute_in_collection(viewer, collection_id))
return payload
@router.get("/api/projects/{project_id}/rfcs")
async def list_project_rfcs(
project_id: str, request: Request, unreviewed: str | None = None
) -> dict[str, Any]:
viewer = auth.current_user(request)
# §22.5 read gate: a gated project's catalog 404s to a non-member.
auth.require_project_readable(viewer, project_id)
# §22 S1: the project-scoped route serves the default collection.
collection_id = collections_mod.default_collection_id(project_id)
return _list_rfcs_for_collection(
collection_id, viewer, unreviewed, query_params=request.query_params
)
@router.get("/api/projects/{project_id}/rfcs/{slug}")
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
collection_id = collections_mod.default_collection_id(project_id)
return _get_rfc_for_collection(collection_id, slug, viewer)
# §22 S2: collection-scoped serve + propose. The catalog/entry views read
# these under /p/<project>/c/<collection>/; the project-scoped routes above
# stay as the default-collection compat surface.
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs")
async def list_collection_rfcs(
project_id: str, collection_id: str, request: Request,
unreviewed: str | None = None,
) -> dict[str, Any]:
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
_require_collection_in_project(collection_id, project_id)
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
auth.require_collection_readable(viewer, collection_id)
return _list_rfcs_for_collection(
collection_id, viewer, unreviewed, query_params=request.query_params
)
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
async def get_collection_rfc(
project_id: str, collection_id: str, slug: str, request: Request
) -> dict[str, Any]:
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
_require_collection_in_project(collection_id, project_id)
auth.require_collection_readable(viewer, collection_id)
return _get_rfc_for_collection(collection_id, slug, viewer)
# ---------------------------------------------------------------
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
# ---------------------------------------------------------------
@router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
"""§22.4c — clear an active entry's `unreviewed` flag. Authority is the
§B.2 collection Owner (a collection/project/global Owner or deployment
owner/admin reaching the entry's collection)."""
viewer = auth.require_user(request)
auth.require_project_readable(viewer, project_id)
collection_id = collections_mod.default_collection_id(project_id)
if not auth.is_collection_superuser(viewer, collection_id):
raise HTTPException(403, "Only a collection owner can mark an entry reviewed")
row = db.conn().execute(
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
(slug, collection_id),
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
if row["state"] != "active" or not row["unreviewed"]:
raise HTTPException(409, "Entry is not an unreviewed active entry")
try:
await bot.mark_entry_reviewed(
viewer.as_actor(),
org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""),
slug=slug,
reviewed_by=viewer.gitea_login,
reviewed_at=entry_mod.today(),
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_repo(config, gitea)
return {"ok": True}
# ---------------------------------------------------------------
# §7.3 / §9.3: pending ideas
# ---------------------------------------------------------------
def _proposal_use_case(pr_number: int) -> str | None:
"""Roadmap #26: read the optional use case for an idea PR from the
canonical side table. Returns None when none was supplied (the
"left blank" sentinel the frontend renders tastefully)."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
async def list_proposals(request: Request) -> dict[str, Any]:
# §22.5: idea PRs in a gated project never surface to non-members.
visible = auth.visible_project_ids(auth.current_user(request))
if not visible:
return {"items": []}
placeholders = ",".join("?" for _ in visible)
rows = db.conn().execute(
"""
f"""
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
FROM cached_prs
WHERE pr_kind = 'idea' AND state = 'open'
AND project_id IN ({placeholders})
ORDER BY opened_at DESC
"""
""",
visible,
).fetchall()
return {
"items": [
@@ -408,6 +973,36 @@ def make_router(
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
"proposed_use_case": _proposal_use_case(r["pr_number"]),
}
for r in rows
]
}
@router.get("/api/projects/{project_id}/proposals")
async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]:
# §22.4/§22.5: the pending idea-PRs scoped to one project.
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
rows = db.conn().execute(
"""
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
FROM cached_prs
WHERE pr_kind = 'idea' AND state = 'open' AND project_id = ?
ORDER BY opened_at DESC
""",
(project_id,),
).fetchall()
return {
"items": [
{
"slug": r["rfc_slug"],
"pr_number": r["pr_number"],
"title": r["title"],
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
"proposed_use_case": _proposal_use_case(r["pr_number"]),
}
for r in rows
]
@@ -431,10 +1026,12 @@ def make_router(
).fetchone()
if row is None:
raise HTTPException(404, "Not a proposal PR")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(auth.current_user(request), row["project_id"])
# Read the proposed entry file from the head branch.
slug = row["rfc_slug"]
head = row["head_branch"]
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
result = await gitea.read_file(config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref=head)
entry_payload: dict[str, Any] | None = None
if result:
text, _sha = result
@@ -456,15 +1053,28 @@ def make_router(
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
"proposed_use_case": _proposal_use_case(pr_number),
}
# ---------------------------------------------------------------
# §9.1: propose a new RFC
# ---------------------------------------------------------------
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
# Default-collection wrapper (§22 S1/S2): resolve the project's default
# collection and delegate. Keeps the project-scoped propose routes intact.
return await _propose_into_collection(
project_id, collections_mod.default_collection_id(project_id), payload, user
)
async def _propose_into_collection(
project_id: str, collection_id: str, payload: ProposeBody, user
) -> dict[str, Any]:
# §B.2 (S3): proposing a new entry requires contribute standing in the
# *target collection* — the four-layer scope-role union, with the
# grandfathered implicit-public baseline on the default collection.
if not auth.can_contribute_in_collection(user, collection_id):
raise HTTPException(403, "You do not have contribute access to this collection")
slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug):
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
@@ -474,21 +1084,29 @@ def make_router(
# on every keystroke, since a concurrent submission could land
# between dialog-open and submit.
clash = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
).fetchone()
if clash:
raise HTTPException(409, f"Slug `{slug}` is already taken")
idea_clash = db.conn().execute(
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
(slug,),
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' "
"AND rfc_slug = ? AND project_id = ?",
(slug, project_id),
).fetchone()
if idea_clash:
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
# §22.4b: the target collection's landing state (the per-corpus field
# moved down to the collection in migration 029).
landing_state = (
"active" if collections_mod.collection_initial_state(collection_id) == "active"
else "super-draft"
)
entry = entry_mod.Entry(
slug=slug,
title=payload.title.strip(),
state="super-draft",
state=landing_state,
id=None,
repo=None,
proposed_by=user.email or user.gitea_login,
@@ -503,6 +1121,7 @@ def make_router(
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
unreviewed=(landing_state == "active"),
)
contents = entry_mod.serialize(entry)
pr_title = f"Propose: {entry.title}"
@@ -513,15 +1132,19 @@ def make_router(
f"**Topic:** {entry.title}\n\n"
f"{payload.pitch.strip()}"
)
# §22 S2: write the entry under the target collection's <subfolder>/rfcs.
subfolder = collections_mod.subfolder_of(collection_id)
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
try:
pr = await bot.open_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.content_repo(project_id) or ""),
slug=slug,
file_contents=contents,
pr_title=pr_title,
pr_description=pr_description,
rfcs_dir=rfcs_dir,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
@@ -532,8 +1155,84 @@ def make_router(
# cache write is idempotent.)
await cache.refresh_meta_pulls(config, gitea)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the idea PR number. NULL/
# blank simply writes no row (absence == "left blank"). Done after
# the refresh so the cache row exists; the mirror onto cached_prs
# keeps the cache column in parity for any read that uses it.
use_case = (payload.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
VALUES ('rfc', ?, ?, ?, ?)
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case, collection_id),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
(use_case, pr["number"], project_id),
)
return {"pr_number": pr["number"], "slug": slug}
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
# Default-project compat path (pre-multi-project clients).
user = auth.require_contributor(request)
return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user)
@router.post("/api/projects/{project_id}/rfcs/propose")
async def propose_project_rfc(
project_id: str, payload: ProposeBody, request: Request
) -> dict[str, Any]:
# §22.4: propose a new entry into a specific project (read-gated first
# so a gated project 404s a non-member before the contribute check).
user = auth.require_contributor(request)
auth.require_project_readable(user, project_id)
return await _propose_into_project(project_id, payload, user)
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose")
async def propose_collection_rfc(
project_id: str, collection_id: str, payload: ProposeBody, request: Request
) -> dict[str, Any]:
# §22 S2: propose a new entry into a specific collection of a project.
user = auth.require_contributor(request)
auth.require_project_readable(user, project_id)
_require_collection_in_project(collection_id, project_id)
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer
# before the contribute check (existence is not revealed).
auth.require_collection_readable(user, collection_id)
return await _propose_into_collection(project_id, collection_id, payload, user)
# ---------------------------------------------------------------
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
# propose-RFC fields fill in. The modal debounce-posts the partial
# draft; we constrain Haiku to the corpus's existing tag set and
# return a short ranked list of clickable chips. Gated to
# contributors (same gate as propose) so the cost surface is bounded
# to people who can actually file an RFC; rate-limited per user as a
# backstop. Degrades to an empty list (no error) when no Anthropic
# key is bound, the corpus has no tags yet, or the draft is empty —
# so the modal simply shows nothing extra.
# ---------------------------------------------------------------
@router.post("/api/rfcs/suggest-tags")
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
if not tag_suggest.rate_limit_ok(user.user_id):
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
provider = tag_suggest.haiku_provider(config)
if provider is None:
return {"suggestions": []}
universe = tag_suggest.gather_tag_universe()
draft = tag_suggest.Draft(
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
)
suggestions = tag_suggest.suggest(provider, draft, universe)
return {"suggestions": suggestions}
# ---------------------------------------------------------------
# §9.3: merge / decline / withdraw an idea PR
# ---------------------------------------------------------------
@@ -546,7 +1245,7 @@ def make_router(
await bot.merge_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
)
@@ -566,7 +1265,7 @@ def make_router(
await bot.decline_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
comment=body.comment,
@@ -590,7 +1289,7 @@ def make_router(
await bot.withdraw_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=config.meta_repo,
meta_repo=(projects_mod.default_content_repo(config) or ""),
pr_number=pr_number,
slug=row["rfc_slug"],
)
@@ -646,6 +1345,8 @@ def make_router(
).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(user, auth.project_of_rfc(slug))
# §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface
# should fail loudly rather than silently.
@@ -694,6 +1395,10 @@ def _serialize_rfc(row) -> dict[str, Any]:
"arbiters": json.loads(row["arbiters_json"] or "[]"),
"tags": json.loads(row["tags_json"] or "[]"),
"body": row["body"] or "",
"metadata_malformed": bool(row["metadata_malformed"]),
# §22.4a SLICE-4: the full per-entry metadata mapping (known + custom
# fields) so the detail panel can render schema-driven controls.
"meta": json.loads(row["meta_json"] or "{}"),
}
+118
View File
@@ -672,6 +672,73 @@ def make_router(config: Config) -> APIRouter:
"has_more": len(rows) == limit,
}
@router.get("/api/admin/outbound-emails")
async def list_outbound_emails(
request: Request,
kind: str | None = None,
status: str | None = None,
to_address: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
before_id: int | None = None,
) -> dict[str, Any]:
"""v0.18.0 Slice 4: read-only inspection of the
`outbound_emails` audit table.
Answers questions like "did this person ever get their
invite?" without grepping VM logs. Filterable by kind
('otc' | 'invite' | 'notification' | 'bundle' | 'digest'),
status ('sent' | 'failed' | 'deferred' | 'bounced'), and
to_address; the latter is exact-match because the audit
question is usually "the specific person who said they
didn't receive it." Per the proposal, no admin UI ships
with v0.18.0 operator queries via curl + jq for now.
"""
auth.require_admin(request)
clauses: list[str] = []
args: list[Any] = []
if kind:
clauses.append("kind = ?")
args.append(kind)
if status:
clauses.append("status = ?")
args.append(status)
if to_address:
clauses.append("LOWER(to_address) = LOWER(?)")
args.append(to_address)
if before_id is not None:
clauses.append("id < ?")
args.append(before_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db.conn().execute(
f"""
SELECT id, to_address, from_address, subject, kind, sent_at,
status, error, notification_id, message_id
FROM outbound_emails
{where}
ORDER BY id DESC
LIMIT ?
""",
(*args, limit),
).fetchall()
return {
"items": [
{
"id": r["id"],
"to_address": r["to_address"],
"from_address": r["from_address"],
"subject": r["subject"],
"kind": r["kind"],
"sent_at": r["sent_at"],
"status": r["status"],
"error": r["error"],
"notification_id": r["notification_id"],
"message_id": r["message_id"],
}
for r in rows
],
"has_more": len(rows) == limit,
}
@router.get("/api/admin/permission-events")
async def list_permission_events(
request: Request,
@@ -762,6 +829,57 @@ def make_router(config: Config) -> APIRouter:
items_blocked.append(payload)
return {"ready": items_ready, "blocked": items_blocked}
# ----- §13.7: retired (soft-deleted) entries — site owners only -----
@router.get("/api/admin/retired-rfcs")
async def retired_rfcs(request: Request) -> dict[str, Any]:
# Retired entries are hidden from every browsing surface (§13.7);
# this owner-gated list is how a site owner discovers them to
# un-retire. Admins do not get this surface — un-retire authority is
# site-owner-only, so neither is the list that feeds it.
viewer = auth.require_admin(request)
if viewer.role != "owner":
raise HTTPException(403, "Site owner role required")
rows = db.conn().execute(
"""
SELECT slug, title, rfc_id, owners_json, tags_json,
proposed_at, updated_at
FROM cached_rfcs
WHERE state = 'retired'
ORDER BY updated_at DESC
"""
).fetchall()
items = []
for r in rows:
# The state it would return to on un-retire, mirroring
# api_graduation._prior_state_before_retire's audit lookup.
prior = db.conn().execute(
"""
SELECT details FROM actions
WHERE rfc_slug = ? AND action_kind = 'retire'
ORDER BY id DESC LIMIT 1
""",
(r["slug"],),
).fetchone()
restored_state = None
if prior and prior["details"]:
try:
restored_state = json.loads(prior["details"]).get("prior_state")
except (ValueError, TypeError):
restored_state = None
if restored_state not in ("super-draft", "active"):
restored_state = "active" if r["rfc_id"] else "super-draft"
items.append({
"slug": r["slug"],
"title": r["title"],
"id": r["rfc_id"],
"owners": json.loads(r["owners_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"retired_at": r["updated_at"],
"restores_to": restored_state,
})
return {"items": items}
# ----- User search (typeahead for §15.8 mute add) -----
@router.get("/api/users/search")
+159 -100
View File
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -40,6 +40,37 @@ log = logging.getLogger(__name__)
RFC_FILE_PATH = "RFC.md"
# ---------------------------------------------------------------------------
# §22.4a SLICE-4: sidecar-aware body extract/wrap (pure, unit-testable)
# ---------------------------------------------------------------------------
def _extract_body_pure(rfc, file_contents: str, branch: str, *, is_meta: bool) -> str:
"""Editable body of an entry file. Meta-resident files carry a frontmatter
envelope (legacy) or are already body-only (migrated, §22.4a); per-RFC repo
files are body-only. Dual-read tolerant: a body-only `.md` returns as-is."""
if not is_meta:
return file_contents
return metadata_mod.strip_frontmatter(file_contents)
def _wrap_body_pure(rfc, prior_contents: str, new_body: str, branch: str, *, is_meta: bool) -> str:
"""Inverse of `_extract_body_pure`. Under §22.4a the body lives in the `.md`
and metadata in the sidecar, so wrapping is identity for body-only files
frontmatter is never re-grown here. A legacy un-migrated meta file still has
its metadata in the `.md` frontmatter (no sidecar yet), so preserve it rather
than silently dropping it on a pure body edit; it is migrated to body-only on
its next *metadata* edit."""
nb = new_body if new_body.endswith("\n") else new_body + "\n"
if not is_meta:
return nb
if entry_mod.FRONTMATTER_RE.match(prior_contents):
e = entry_mod.parse(prior_contents)
e.body = nb
return entry_mod.serialize(e)
return nb
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
@@ -120,7 +151,9 @@ def make_router(
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/models")
async def list_models_for_rfc(slug: str) -> dict[str, Any]:
async def list_models_for_rfc(slug: str, request: Request) -> dict[str, Any]:
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
_require_rfc(slug, auth.current_user(request))
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
return {
"models": [
@@ -140,7 +173,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/main")
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc(slug)
rfc = _require_rfc(slug, viewer)
if rfc["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {rfc['state']}")
@@ -150,7 +183,7 @@ def make_router(
# `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'.
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
@@ -180,7 +213,7 @@ def make_router(
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute(
f"""
@@ -204,17 +237,18 @@ def make_router(
for r in pr_rows
]
# For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
# any `threads` or `changes` rows whose `branch_name` starts with
# `edit-<slug>-` so the breadcrumb dropdown can render the
# affordance as a distinct disclosure alongside main, open
# branches, and open PRs. The slug is the canonical key per §2.3
# before and after graduation, so the query is a straightforward
# lookup — no data movement.
# For meta-resident entries the cached body is entry.body already
# (see cache._upsert_cached_rfc), so no extraction is needed.
# Pre-graduation history is a LEGACY-only affordance: under the
# meta-only topology (§1, §13.4) graduation moves nothing, so an
# active RFC's edit branches are its *current* branches and already
# surface in `branches` above — there is no separate pre-graduation
# set. The disclosure is therefore computed only for a legacy
# per-RFC-repo active entry (`repo` set), where edit branches on the
# meta repo genuinely predate the per-RFC repo and would otherwise
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active":
if rfc["state"] == "active" and rfc["repo"]:
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
@@ -288,12 +322,24 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login)
_validate_branch_name(new_branch)
# Meta-only topology (§1): an active RFC's branches live on the
# shared meta repo, so the auto name must embed the slug for the
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
# the slug-free `<login>-draft-<hex>` since every branch there
# belongs to the one RFC. The auto name is trusted (it carries a
# reserved `edit-` prefix by design); only a user-supplied name
# is validated, mirroring `start_edit_branch`.
new_branch = (
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
else _auto_branch_name(viewer.gitea_login)
)
else:
_validate_branch_name(new_branch)
try:
await bot.cut_branch_from_main(
viewer.as_actor(),
@@ -326,8 +372,10 @@ def make_router(
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop.
await cache.refresh_rfc_repo(config, gitea, slug)
# the new branch without waiting for the webhook hop. Meta-resident
# entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
# its own — `_refresh_cache_for` dispatches on residency.
await _refresh_cache_for(rfc)
return {"branch_name": new_branch, "slug": slug}
@@ -348,7 +396,7 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
@@ -393,7 +441,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/metadata")
async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
# Permission: super-draft owners/arbiters per §6.3, plus app-wide
# admins/owners per §6.1. Until claim, that collapses to admin/owner.
if not _can_edit_metadata(rfc, viewer):
@@ -466,7 +514,7 @@ def make_router(
request: Request,
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -553,7 +601,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline")
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -571,7 +619,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask")
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -638,7 +686,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush")
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
@@ -715,7 +763,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility")
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
current = _branch_vis(slug, branch)
@@ -725,7 +773,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, ?, ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
read_public = excluded.read_public,
contribute_mode = excluded.contribute_mode
""",
@@ -736,7 +784,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/grants")
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -757,7 +805,7 @@ def make_router(
@router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}")
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -777,7 +825,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads")
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
rows = db.conn().execute(
@@ -795,7 +843,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads")
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if body.thread_kind == "flag" and not body.label:
raise HTTPException(422, "Flag threads require a label")
cur = db.conn().execute(
@@ -825,7 +873,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages")
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
thread = _require_thread(slug, branch, thread_id)
@@ -850,7 +898,7 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
_require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -871,7 +919,7 @@ def make_router(
to this (slug, branch) on or before the new cursor is marked read.
"""
viewer = auth.require_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
last_seen = int(body.get("last_seen_message_id") or 0) or None
@@ -879,7 +927,7 @@ def make_router(
"""
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, user_id, rfc_slug, branch_name) DO UPDATE SET
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
""",
@@ -894,7 +942,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve")
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
thread = _require_thread(slug, branch, thread_id)
creator = _branch_creator(slug, branch)
if not _can_resolve_thread(rfc, thread, creator, viewer):
@@ -917,7 +965,7 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
):
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -1003,7 +1051,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -1074,31 +1122,34 @@ def make_router(
# Permission + state helpers (closures, share `config` etc.)
# ------------------------------------------------------------------
def _require_rfc(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members — indistinguishable from an unknown slug.
auth.require_project_readable(viewer, row["project_id"])
return row
def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
required. For super-drafts, the meta repo is the implicit target
no per-RFC repo check needed."""
row = _require_rfc(slug)
def _require_rfc_with_repo(slug: str, viewer):
"""Used by every branch-scoped endpoint. Under the meta-only
topology (§1) the meta repo is the implicit target for every
entry super-draft and active alike so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug, viewer)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _require_active_rfc(slug: str):
row = _require_rfc_with_repo(slug)
def _require_active_rfc(slug: str, viewer):
row = _require_rfc_with_repo(slug, viewer)
if row["state"] != "active":
raise HTTPException(409, f"RFC is {row['state']}, not active")
return row
def _require_super_draft(slug: str):
row = _require_rfc(slug)
def _require_super_draft(slug: str, viewer):
row = _require_rfc(slug, viewer)
if row["state"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
@@ -1106,28 +1157,34 @@ def make_router(
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6)."""
return not rfc["repo"]
def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view
at branches matching `edit-<slug>-...` even after the entry is
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
Retained for the legacy per-RFC-repo read path; under meta-only
every entry is already a meta target via `_is_meta_resident`."""
return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-",
))
def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the
canonical body) or an active RFC's pre-graduation meta-repo
branch surfaced through the §9.8 history affordance."""
if _is_super_draft(rfc):
"""A meta-resident entry (super-draft or active-in-place, §1)
targets the meta repo for every branch. The branch-name fallback
covers the retired per-RFC-repo case for any legacy entry that
still carries a `repo:`."""
if _is_meta_resident(rfc):
return True
return _is_meta_branch_name(branch)
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
if _is_meta_target(rfc, branch):
return config.gitea_org, config.meta_repo
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
@@ -1137,31 +1194,15 @@ def make_router(
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str, branch: str = "main") -> str:
"""For super-draft entries (and active-RFC pre-graduation reads
per §9.8) the file on disk is the full frontmatter+body envelope;
the editable body is entry.body. For active RFCs reading their
per-RFC repo the file is just RFC.md and the whole thing is body."""
if not _is_meta_target(rfc, branch):
return file_contents
try:
entry = entry_mod.parse(file_contents)
except Exception:
return file_contents
return entry.body
return _extract_body_pure(
rfc, file_contents, branch, is_meta=_is_meta_target(rfc, branch))
def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str:
"""Inverse of _extract_body: re-wrap a new body into the entry
envelope, preserving the prior frontmatter exactly."""
if not _is_meta_target(rfc, branch):
return new_body
entry = entry_mod.parse(prior_contents)
# Ensure exactly one trailing newline so the serializer's
# round-trip is stable.
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
return entry_mod.serialize(entry)
return _wrap_body_pure(
rfc, prior_contents, new_body, branch, is_meta=_is_meta_target(rfc, branch))
async def _refresh_cache_for(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
else:
@@ -1238,6 +1279,12 @@ def make_router(
return row["on_behalf_of"] if row else None
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
# §22.5 visibility gate first (subtractive, §B.2): in a hidden/gated
# collection nothing — not even main or a read_public branch — is
# readable by a non-scope-role viewer.
cid = auth.collection_of_rfc(slug)
if not auth.can_read_collection(viewer, cid):
return False
if branch == "main":
return True
vis = _branch_vis(slug, branch)
@@ -1245,7 +1292,7 @@ def make_router(
return True
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_collection_superuser(viewer, cid):
return True
creator = _branch_creator(slug, branch)
if creator and viewer.gitea_login == creator:
@@ -1270,14 +1317,20 @@ def make_router(
return False
if branch == "main":
return False
# §9.8: pre-graduation history branches are read-only on the
# post-graduation surface. The contributor can re-cut against the
# new repo's main if they still want the work, but the meta-repo
# branches that lived on the super-draft are not editable from
# the active-RFC view.
if rfc["state"] == "active" and _is_meta_branch_name(branch):
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
# read-only on the post-graduation surface of a per-RFC-repo active
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
# `edit-<slug>-…` branches are its *current* editable branches, not
# a frozen pre-graduation set, so this guard applies only when a
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False
if viewer.role in ("owner", "admin"):
cid = auth.collection_of_rfc(slug)
# §22.5 visibility gate (subtractive): no contribute in an unreadable
# collection.
if not auth.can_read_collection(viewer, cid):
return False
if auth.is_collection_superuser(viewer, cid):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1288,7 +1341,10 @@ def make_router(
return True
vis = _branch_vis(slug, branch)
if vis["contribute_mode"] == "any-contributor":
return True
# "any contributor" means anyone with collection-level write standing
# (§B.2) — the grandfathered baseline on the public default
# collection, or an explicit scope grant reaching the collection.
return auth.can_contribute_in_collection(viewer, cid)
if vis["contribute_mode"] == "specific":
row = db.conn().execute(
"""
@@ -1302,14 +1358,16 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute(
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
"SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if not _can_contribute(rfc, slug, branch, viewer):
raise HTTPException(403, "You do not have contribute access to this branch")
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
if viewer.role in ("owner", "admin"):
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted
# to project scope, so it (and a deployment owner/admin) clears here.
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1320,11 +1378,12 @@ def make_router(
raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings")
def _can_edit_metadata(rfc, viewer) -> bool:
"""§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners.
Until §13.1's claim runs, the super-draft has no owners, so the set
collapses to app admins/owners only sensible because admin oversight
is the only path to canonicalizing edits on an unclaimed entry."""
if viewer.role in ("owner", "admin"):
"""§9.5: super-draft owners/arbiters per §6.3 plus project_admin /
app admins/owners (§22.6). Until §13.1's claim runs, the super-draft
has no owners, so the set collapses to the superuser tier only
sensible because admin oversight is the only path to canonicalizing
edits on an unclaimed entry."""
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1337,7 +1396,7 @@ def make_router(
"can_read": _can_read_branch(slug, branch, viewer),
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
"can_change_branch_settings": viewer is not None and (
viewer.role in ("owner", "admin")
auth.is_collection_superuser(viewer, rfc["collection_id"])
or (creator is not None and viewer.gitea_login == creator)
or viewer.gitea_login in (owners + arbiters)
),
@@ -1383,7 +1442,7 @@ def make_router(
def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
+182
View File
@@ -0,0 +1,182 @@
"""§22 S2 — collection directory + create-collection.
GET /api/projects/:id/collections list the project's visible collections.
GET /api/projects/:id/collections/:cid one collection's settings.
POST /api/projects/:id/collections create a collection. Authorized by a
deployment owner/admin (S2; scoped
{owner, contributor} roles at the
collection axis land in S3). The bot
commits a `.collection.yaml` to the
content repo, then the registry mirror
upserts the collections row §22.2
keeps the registry the source of truth.
"""
from __future__ import annotations
import re
from typing import Any
import yaml
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from . import (
auth,
collections as collections_mod,
projects as projects_mod,
registry as registry_mod,
)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
class CreateCollectionBody(BaseModel):
collection_id: str
type: str
name: str | None = None
visibility: str | None = None
initial_state: str | None = None
def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]:
"""§22 S4: the viewer's project-grain capabilities for role-aware UI — may
they create a collection, may they manage membership (invite), and their
project role. `role` maps the §22.6 legacy strings back to the unified
`{owner, contributor}` vocabulary the frontend speaks."""
legacy = auth.project_member_role(viewer, project_id)
role = None
if viewer is not None and viewer.role in ("owner", "admin"):
role = "owner"
elif legacy == "project_admin":
role = "owner"
elif legacy == "project_contributor":
role = "contributor"
return {
"can_create_collection": auth.can_create_collection(viewer, project_id),
"can_invite": auth.can_invite_at_project(viewer, project_id),
# §22.8: a signed-in, granted account with no role at the project may ask
# to join it (the request-to-join affordance). Owners/members and
# not-yet-granted accounts don't see it.
"can_request_join": (
viewer is not None
and viewer.permission_state == "granted"
and auth.effective_role_at_scope(viewer, "project", project_id) is None
),
"role": role,
}
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
router = APIRouter()
@router.get("/api/projects/{project_id}/collections")
async def list_cols(project_id: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
# §22.5 read gate: a gated project 404s a non-member.
auth.require_project_readable(viewer, project_id)
# §22.5 (S3): the directory is viewer-aware — a hidden/gated collection
# is listed only for a scope-role holder who can read it; `unlisted` is
# omitted from enumeration for everyone (link-only).
items = [
c
for c in collections_mod.list_collections(project_id, include_unlisted=True)
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
]
# §22 S4: surface the viewer's project-level capabilities so the
# directory can render role-aware affordances (the create-first-
# collection CTA, the invite control) without a second round-trip.
return {"items": items, "viewer": _project_viewer_caps(viewer, project_id)}
@router.get("/api/projects/{project_id}/collections/{collection_id}")
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
col = collections_mod.get_collection(collection_id)
if col is None or col["project_id"] != project_id:
raise HTTPException(404, "Not found")
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer.
auth.require_collection_readable(viewer, collection_id)
# §22 S4: the viewer's collection-level capabilities drive the
# propose-first empty state and the collection invite control.
col = dict(col)
col["viewer"] = {
"can_contribute": auth.can_contribute_in_collection(viewer, collection_id),
"can_invite": auth.can_invite_at_collection(viewer, collection_id),
# §22.8: a signed-in, granted account with no role reaching this
# collection may ask to join it.
"can_request_join": (
viewer is not None
and viewer.permission_state == "granted"
and auth.effective_scope_role(viewer, collection_id) is None
),
"role": auth.effective_scope_role(viewer, collection_id),
}
return col
@router.post("/api/projects/{project_id}/collections")
async def create_col(
project_id: str, body: CreateCollectionBody, request: Request
) -> dict[str, Any]:
# §B.1 (S3) authority: a deployment owner/admin or a project/global-scope
# grant holder (Owner or RFC Contributor) may create a collection. The
# read gate runs first so a gated project 404s a non-member.
user = auth.require_contributor(request)
auth.require_project_readable(user, project_id)
if not auth.can_create_collection(user, project_id):
raise HTTPException(403, "You may not create collections in this project")
cid = body.collection_id.strip().lower()
if not _SLUG_RE.match(cid) or cid == "default":
raise HTTPException(422, "collection id must be a slug and not 'default'")
if body.type not in registry_mod.VALID_TYPES:
raise HTTPException(422, f"invalid type {body.type!r}")
if body.visibility is not None:
if body.visibility not in registry_mod.VALID_VISIBILITY:
raise HTTPException(422, f"invalid visibility {body.visibility!r}")
# §22.5 (S3) strictness: a collection may be set only as strict or
# stricter than its project — never more public.
pvis = auth.project_visibility(project_id)
if auth.visibility_rank(body.visibility) < auth.visibility_rank(pvis):
raise HTTPException(
422,
f"collection visibility {body.visibility!r} is looser than "
f"the project's {pvis!r}; a collection may only narrow it",
)
if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE:
raise HTTPException(422, f"invalid initial_state {body.initial_state!r}")
if collections_mod.get_collection(cid) is not None:
raise HTTPException(409, f"collection `{cid}` already exists")
content_repo = projects_mod.content_repo(project_id)
if not content_repo:
raise HTTPException(409, "project has no content repo")
manifest: dict[str, Any] = {"type": body.type}
if body.name:
manifest["name"] = body.name
if body.visibility:
manifest["visibility"] = body.visibility
if body.initial_state:
manifest["initial_state"] = body.initial_state
manifest_yaml = yaml.safe_dump(manifest, sort_keys=False)
try:
await bot.create_collection(
user.as_actor(),
org=config.gitea_org,
content_repo=content_repo,
collection_id=cid,
manifest_yaml=manifest_yaml,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# §22.2: re-read the registry so the new manifest becomes a row.
await registry_mod.refresh_registry(config, gitea)
col = collections_mod.get_collection(cid)
if col is None:
raise HTTPException(500, "collection committed but not mirrored")
return col
return router
+316
View File
@@ -0,0 +1,316 @@
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
PR/comment text to a **pending** RFC a super-draft
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
contribution surface, but not yet graduated to an active RFC) the
reader is offered an "ask to contribute" popover. This module is the
backend for that flow:
* ``GET /api/rfcs/{slug}/contribution-target`` what the
contribute form needs (RFC title, owner display, the viewer's
eligibility + whether they already have a pending ask).
* ``POST /api/rfcs/{slug}/contribution-requests`` submit the ask
(who-I-am / why / optional use-case); lands a row + one §15
notification per owner.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` owner:
accept, which fires #12's owner-invite flow with the requester as the
invitee (opening the RFC's discussion/contribution surface), then
echoes a notification back to the requester.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` owner:
decline; the request closes and the requester is notified.
"Pending" is scoped to a super-draft because that is the state with an
owner to route to, a contribution surface to open, and a row in
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
Pre-merge idea PRs are deliberately out of scope (no contribution
surface yet) a documented future extension, mirroring the
conservative scoping in ``rfc_links.py``.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_invitations, auth, db, notify
# Field caps — generous for free text, bounded so a request row (and the
# notification payload that carries it) can't be used to store unbounded
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
_WHO_MAX = 2000
_WHY_MAX = 4000
_USE_CASE_MAX = 4000
_TERM_MAX = 200
class ContributionRequestBody(BaseModel):
# The term in the PR/comment text that surfaced the offer (the
# super-draft's title/slug). Carried for the owner's context line.
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
why: str = Field(min_length=1, max_length=_WHY_MAX)
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
def _require_super_draft(slug: str, viewer):
"""The contribute surface only operates on a *pending* RFC. 404 on
unknown; 409 on a state that isn't a super-draft (active RFCs use the
Part-1 link, not a contribute offer; withdrawn is closed). The §22.5
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
auth.require_project_readable(viewer, row["project_id"])
if row["state"] != "super-draft":
raise HTTPException(409, "RFC is not a pending super-draft")
return row
def _require_request(slug: str, request_id: int):
row = db.conn().execute(
"""
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
use_case, status
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
""",
(request_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Contribution request not found")
return row
def _viewer_relationship(viewer, slug: str) -> str | None:
"""Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request."""
if auth.is_rfc_owner(viewer, slug) or auth.is_collection_superuser(viewer, auth.collection_of_rfc(slug)):
return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC."
return None
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# GET — what the contribute form needs to render + gate itself.
# ---------------------------------------------------------------
@router.get("/api/rfcs/{slug}/contribution-target")
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
from . import rfc_links # local import: avoid a module import cycle
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
eligible = True
reason: str | None = None
already_requested = False
if row["state"] != "super-draft":
eligible, reason = False, "This RFC is no longer pending."
elif viewer is None:
eligible, reason = False, "Sign in to ask to contribute."
elif viewer.permission_state != "granted":
eligible, reason = False, "Your beta access request is in review."
else:
reason = _viewer_relationship(viewer, slug)
if reason is not None:
eligible = False
else:
already_requested = bool(
db.conn().execute(
"""
SELECT 1 FROM contribution_requests
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
LIMIT 1
""",
(slug, viewer.user_id),
).fetchone()
)
return {
"slug": row["slug"],
"title": row["title"],
"owner": owner,
"eligible": eligible and not already_requested,
"reason": reason,
"already_requested": already_requested,
}
# ---------------------------------------------------------------
# POST — submit a contribute request.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests")
async def create_contribution_request(
slug: str, body: ContributionRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug, viewer)
reason = _viewer_relationship(viewer, slug)
if reason is not None:
raise HTTPException(409, reason)
who_i_am = body.who_i_am.strip()
why = body.why.strip()
use_case = (body.use_case or "").strip() or None
matched_term = body.matched_term.strip()
if not who_i_am or not why:
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
try:
cur = db.conn().execute(
"""
INSERT INTO contribution_requests
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
VALUES (?, ?, ?, ?, ?, ?)
""",
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
)
except sqlite3.IntegrityError:
# The partial unique index — one open request per (RFC, user).
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
request_id = cur.lastrowid
# One actionable notification per owner; stamp the first onto the
# row as the inbox-action handle (any owner can act on the request).
notif_ids = notify.fan_out_contribution_request(
rfc_slug=slug,
requester_user_id=viewer.user_id,
request_id=request_id,
matched_term=matched_term,
who_i_am=who_i_am,
why=why,
use_case=use_case,
)
if notif_ids:
db.conn().execute(
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
(notif_ids[0], request_id),
)
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
# ---------------------------------------------------------------
# POST — owner accepts → fire #12's invite flow.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
async def accept_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
requester = db.conn().execute(
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
).fetchone()
if requester is None or not (requester["email"] or "").strip():
raise HTTPException(422, "The requester has no email address on file to invite.")
# Fire #12's owner-invite flow with the requester as the invitee.
# If a pending contributor invitation already exists (the owner
# invited them out-of-band first), reuse it rather than failing.
try:
invitation = api_invitations.issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=requester["email"],
role_in_rfc="contributor",
rfc_title=rfc["title"],
)
invitation_id = invitation["id"]
except HTTPException as exc:
if exc.status_code != 409:
raise
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = 'contributor' AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(slug, requester["email"].strip()),
).fetchone()
invitation_id = existing["id"] if existing else None
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'accepted', decided_at = datetime('now'),
decided_by_user_id = ?, invitation_id = ?
WHERE id = ?
""",
(viewer.user_id, invitation_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=True,
)
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
# ---------------------------------------------------------------
# POST — owner declines.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
async def decline_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'declined', decided_at = datetime('now'),
decided_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=False,
)
return {"ok": True, "status": "declined"}
return router
+263
View File
@@ -0,0 +1,263 @@
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
old-URL 308 redirects + §22 S5 in-app create-project.
GET /api/deployment the deployment name/tagline + the projects the caller can
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
plus the corpus-served `default_project_id` the M3-frontend guard keys on, the
`viewer` capability block (S5: `can_create_project`), and
`default_project_readable` (whether the N=1 redirect target is reachable by this
viewer drives the deployment-directory empty state vs the land-in-corpus
redirect).
POST /api/projects §22 S5 create-project (global-Owner only). The bot
provisions a Gitea content repo and commits a project entry to `projects.yaml`;
the registry mirror then upserts the `projects` + default `collections` rows
(§22.2 keeps the registry the source of truth).
GET /api/projects/:id one project's runtime config + optional theme overlay,
gated behind the §22.5 read gate (404 for a non-member of a gated project).
GET /rfc/{slug}, /proposals/{n} §22.10 server-side 308s onto the new
`/p/<default>/` routes (the SPA no longer owns these paths; nginx proxies them
to the backend instead of serving index.html).
"""
from __future__ import annotations
import json
import re
from typing import Any
import yaml
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from . import (
auth,
collections as collections_mod,
db,
projects as projects_mod,
registry as registry_mod,
)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
# A project id is a slug (the §22.2 registry key + the `/p/<id>/` path segment).
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
# A Gitea repo name: alphanumeric start, then alphanumerics / `-` / `_` / `.`.
_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
class CreateProjectBody(BaseModel):
project_id: str
name: str
type: str
visibility: str | None = None
content_repo: str | None = None
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
router = APIRouter()
@router.get("/api/deployment")
async def get_deployment(request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
dep = db.conn().execute(
"SELECT name, tagline FROM deployment WHERE id = 1"
).fetchone()
# §22.5: enumerate only public + (member-)gated; unlisted is never listed.
visible = set(auth.visible_project_ids(viewer))
# §22 three-tier: `type` is a per-corpus field on the (default) collection
# now; surface the default collection's type for each project.
rows = db.conn().execute(
"SELECT id, name, visibility FROM projects "
"WHERE visibility != 'unlisted' ORDER BY name"
).fetchall()
projects = [
{
"id": r["id"],
"name": r["name"],
"type": collections_mod.collection_type(
collections_mod.default_collection_id(r["id"])
),
"entry_noun": collections_mod.entry_noun(
collections_mod.collection_type(
collections_mod.default_collection_id(r["id"])
)
),
"visibility": r["visibility"],
}
for r in rows
if r["id"] in visible
]
# §22 S5: the N=1 land-in-corpus redirect targets the default project, but
# only when this viewer can actually read it. A `gated` default (C3.2) or
# an absent default (C3.1, a deployment with no projects) is *not* a valid
# redirect target — the frontend then falls through to the deployment
# directory's role-aware empty state instead of bouncing into a 404.
default_id = projects_mod.resolved_default_id(config)
default_exists = db.conn().execute(
"SELECT 1 FROM projects WHERE id = ?", (default_id,)
).fetchone() is not None
default_readable = default_exists and auth.can_read_project(viewer, default_id)
return {
"name": (dep["name"] if dep else "") or "",
"tagline": (dep["tagline"] if dep else "") or "",
# §22.10 / M3-frontend guard contract: which project the backend
# serves the corpus for (the default, until Plan B serves per
# project). The frontend renders corpus routes only for this id and
# shows a "content not yet served" placeholder for any other.
"default_project_id": default_id,
"default_project_readable": default_readable,
"projects": projects,
# §22 S5 (C3.1/C3.2): role-aware deployment-directory affordances.
"viewer": {"can_create_project": auth.can_create_project(viewer)},
}
@router.post("/api/projects")
async def create_project(body: CreateProjectBody, request: Request) -> dict[str, Any]:
# §22 S5 / §A.2 / §B.1: "+ New project" is a global-Owner action.
user = auth.require_contributor(request)
if not auth.can_create_project(user):
raise HTTPException(403, "Only a global Owner may create projects")
pid = body.project_id.strip().lower()
if not _SLUG_RE.match(pid) or pid == "default":
raise HTTPException(422, "project id must be a slug and not 'default'")
name = (body.name or "").strip()
if not name:
raise HTTPException(422, "project name is required")
if body.type not in registry_mod.VALID_TYPES:
raise HTTPException(422, f"invalid type {body.type!r}")
# A project is created visible by default — the point of standing one up
# is for it to be seen; an Owner narrows it afterwards (or picks gated).
visibility = (body.visibility or "public").strip()
if visibility not in registry_mod.VALID_VISIBILITY:
raise HTTPException(422, f"invalid visibility {visibility!r}")
if db.conn().execute("SELECT 1 FROM projects WHERE id = ?", (pid,)).fetchone():
raise HTTPException(409, f"project `{pid}` already exists")
content_repo = (body.content_repo or f"{pid}-content").strip()
if not _REPO_RE.match(content_repo):
raise HTTPException(422, f"invalid content repo name {content_repo!r}")
if await gitea.get_repo(config.gitea_org, content_repo) is not None:
raise HTTPException(409, f"repo `{content_repo}` already exists")
# Read the current registry, append the project, recompose. Reads live in
# gitea.py and may be called anywhere; the bot owns the write back.
read = await gitea.read_file(
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
)
if read is None:
raise HTTPException(409, "registry projects.yaml not found")
text, sha = read
try:
doc = yaml.safe_load(text) or {}
except yaml.YAMLError as e:
raise HTTPException(500, f"registry projects.yaml is not valid YAML: {e}")
if not isinstance(doc, dict):
raise HTTPException(500, "registry projects.yaml is malformed")
projects = doc.get("projects")
if not isinstance(projects, list):
projects = []
if any(isinstance(p, dict) and str(p.get("id") or "") == pid for p in projects):
raise HTTPException(409, f"project `{pid}` already in the registry")
projects.append(
{
"id": pid,
"name": name,
"type": body.type,
"content_repo": content_repo,
"visibility": visibility,
}
)
doc["projects"] = projects
new_text = yaml.safe_dump(doc, sort_keys=False)
readme_text = f"# {name}\n\nContent repository for project `{pid}`.\n"
try:
await bot.create_project(
user.as_actor(),
org=config.gitea_org,
registry_repo=config.registry_repo,
content_repo=content_repo,
project_id=pid,
projects_yaml_new=new_text,
projects_yaml_sha=sha,
readme_text=readme_text,
)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
# §22.2: re-read the registry so the new entry becomes projects +
# default-collection rows.
await registry_mod.refresh_registry(config, gitea)
row = db.conn().execute(
"SELECT id, name, visibility FROM projects WHERE id = ?", (pid,)
).fetchone()
if row is None:
raise HTTPException(500, "project committed but not mirrored")
cid = collections_mod.default_collection_id(pid)
return {
"id": row["id"],
"name": row["name"],
"visibility": row["visibility"],
"type": collections_mod.collection_type(cid),
}
@router.get("/api/projects/{project_id}")
async def get_project(project_id: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
# §22.5 read gate: a gated project 404s to a non-member (shape matches
# an unknown id). unlisted is readable by direct id.
auth.require_project_readable(viewer, project_id)
row = db.conn().execute(
"SELECT id, name, visibility, config_json FROM projects WHERE id = ?",
(project_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Not found")
try:
cfg = json.loads(row["config_json"] or "{}")
except (ValueError, TypeError):
cfg = {}
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
# §22 three-tier: type + initial_state moved down to the (default)
# collection in migration 029.
cid = collections_mod.default_collection_id(row["id"])
return {
"id": row["id"],
"name": row["name"],
"tagline": (dep["tagline"] if dep else "") or "",
"type": collections_mod.collection_type(cid),
"entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)),
"visibility": row["visibility"],
"initial_state": collections_mod.collection_initial_state(cid),
"theme": cfg.get("theme") or {},
}
# §22.10 / §5 — server-side 308s off the old corpus-root URLs onto the
# `/p/<default>/…` routes. 308 (not 301/302) preserves method + body and
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
# before the SPA's index.html fallback.
# §22 three-tier (S1): the canonical entry route now carries the collection
# segment /p/<project>/c/<collection>/…. The legacy roots redirect through
# the default project's default collection.
@router.get("/rfc/{slug}")
async def redirect_old_rfc(slug: str) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308)
@router.get("/rfc/{slug}/pr/{pr_number}")
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(
url=f"/p/{default_id}/c/{cid}/e/{slug}/pr/{pr_number}", status_code=308
)
@router.get("/proposals/{pr_number}")
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/proposals/{pr_number}", status_code=308)
return router
+29 -14
View File
@@ -40,7 +40,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, chat as chat_layer, db
from . import auth, chat as chat_layer, db, rfc_links
log = logging.getLogger(__name__)
@@ -84,7 +84,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/discussion/threads")
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# Ensure the default whole-doc discussion thread exists. We mint
# it on first read regardless of viewer (anonymous viewers can
# trigger the creation — the row's `created_by` is null in that
@@ -115,7 +115,7 @@ def make_router() -> APIRouter:
slug: str, body: DiscussionThreadCreateBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
# gated surface. The platform-level `require_contributor` above
# ensures the user is signed in + admin-granted; this layer
@@ -155,8 +155,8 @@ def make_router() -> APIRouter:
async def get_discussion_thread_messages(
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
_viewer = auth.current_user(request)
_require_rfc_readable(slug)
viewer = auth.current_user(request)
_require_rfc_readable(slug, viewer)
thread = _require_discussion_thread(slug, thread_id)
rows = db.conn().execute(
"""
@@ -171,9 +171,17 @@ def make_router() -> APIRouter:
""",
(thread_id,),
).fetchall()
# Roadmap #28 Part 1: enrich each discussion comment with RFC
# auto-link segments scanned against the live accepted-RFC corpus
# (read-time; see rfc_links.py). exclude_slug suppresses self-links
# to this RFC inside its own discussion.
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
messages = [_serialize_message(r) for r in rows]
for m in messages:
m["text_segments"] = link_index.segment(m["text"])
return {
"thread": _serialize_thread(thread),
"messages": [_serialize_message(r) for r in rows],
"messages": messages,
}
# -------------------------------------------------------------------
@@ -185,7 +193,7 @@ def make_router() -> APIRouter:
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
if not auth.can_discuss_rfc(viewer, slug):
raise HTTPException(
@@ -210,7 +218,7 @@ def make_router() -> APIRouter:
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_readable(slug)
rfc = _require_rfc_readable(slug, viewer)
thread = _require_discussion_thread(slug, thread_id)
if not _can_resolve(rfc, thread, viewer):
raise HTTPException(
@@ -237,17 +245,24 @@ def make_router() -> APIRouter:
# ---------------------------------------------------------------------------
def _require_rfc_readable(slug: str):
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
by anyone. Withdrawn entries refuse reads of every shape same rule
`_require_rfc_with_repo` in `api_branches.py` follows."""
def _require_rfc_readable(slug: str, viewer):
"""Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable*
project is readable by anyone. The §22.5 visibility gate is subtractive on
top (§22.7): a gated project's entries 404 to non-members. Withdrawn
entries refuse reads of every shape same rule `_require_rfc_with_repo`
in `api_branches.py` follows."""
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
auth.require_project_readable(viewer, row["project_id"])
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
# (a 404, not a 409: the entry is not surfaced anywhere a browser looks).
if row["state"] == "retired":
raise HTTPException(404, "RFC not found")
return row
@@ -297,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
def _can_resolve(rfc, thread, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
File diff suppressed because it is too large Load Diff
+86 -65
View File
@@ -126,76 +126,22 @@ def make_router() -> APIRouter:
@router.post("/api/rfcs/{slug}/invitations")
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc(slug)
rfc = _require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can invite collaborators",
)
invitee_email = body.invitee_email.strip()
role_in_rfc = body.role_in_rfc
# Refuse re-inviting an email that already has a pending
# invitation on this RFC at the same role. Different-role
# re-invite is allowed (upgrade discussant → contributor)
# — the new row supersedes the old in the UI listing's
# natural ordering, and acceptance of either picks up the
# corresponding role.
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(
slug,
viewer.user_id,
invitee_email,
role_in_rfc,
token,
f"+{INVITATION_TTL_DAYS} days",
),
)
invitation_id = cur.lastrowid
# Send the email — synchronous. A send failure logs and
# returns; the row stays so the owner can recover via the
# listing (which carries the token for an out-of-band share).
_send_invitation_email(
to_address=invitee_email,
return issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=body.invitee_email,
role_in_rfc=body.role_in_rfc,
rfc_title=rfc["title"],
role_in_rfc=role_in_rfc,
token=token,
)
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
# ---------------------------------------------------------------
# GET /api/rfcs/<slug>/invitations
# The owner's listing of every invitation on the RFC, regardless
@@ -205,7 +151,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/invitations")
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
_require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
@@ -261,7 +207,7 @@ def make_router() -> APIRouter:
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
_require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
@@ -427,15 +373,18 @@ def make_router() -> APIRouter:
# ---------------------------------------------------------------------------
def _require_rfc(slug: str):
def _require_rfc(slug: str, viewer):
"""The invitation surface only operates on a known, non-withdrawn
RFC. We refuse 404 on unknown and 409 on withdrawn mirrors the
discussion endpoints' `_require_rfc_readable` shape."""
discussion endpoints' `_require_rfc_readable` shape. The §22.5
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
"SELECT slug, title, state, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
auth.require_project_readable(viewer, row["project_id"])
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
@@ -473,6 +422,78 @@ def _effective_status(row) -> str:
return "expired" if is_past else "pending"
def issue_invitation(
*,
slug: str,
inviter_user_id: int,
inviter_display: str,
invitee_email: str,
role_in_rfc: str,
rfc_title: str,
) -> dict:
"""Mint + persist + email one ``rfc_invitations`` row.
The single chokepoint for issuing an invitation: the owner's manual
`POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
accept path both route through here, so the dup-guard, token mint,
insert, and transactional email stay identical.
Refuses (409) re-inviting an email that already has a pending
invitation on this RFC at the same role. A different-role re-invite is
allowed (the discussant contributor upgrade) the new row
supersedes the old in the listing's natural ordering, and acceptance
of either picks up the corresponding role.
Returns the new row's dict (including the raw token, for the owner's
out-of-band share / the caller's record-keeping). A send failure logs
and returns; the row stays so the owner can recover via the listing.
"""
invitee_email = invitee_email.strip()
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(slug, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
)
invitation_id = cur.lastrowid
_send_invitation_email(
to_address=invitee_email,
inviter_display=inviter_display,
rfc_title=rfc_title,
role_in_rfc=role_in_rfc,
token=token,
)
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
def _mint_token() -> str:
"""A 256-bit URL-safe token. The token shape is opaque to the
consumer; the email link encodes it as a query param."""
+311
View File
@@ -0,0 +1,311 @@
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
A gated project or collection is invisible to non-members (§22.5), so joining is
by invite (an Owner grants directly `api_memberships.py`) *or* by request: a
user who knows a scope exists asks to join it, naming a desired role. This module
is the request side:
* ``GET /api/scopes/{scope_type}/{scope_id}/join-target`` what the join
form needs (the scope's name, the viewer's eligibility + whether they already
have a pending ask + their current role).
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests`` submit the ask
(desired role + optional message); lands a row + one §15 notification per
Owner across the scope's subtree (the cross-collection inbox, §22.11).
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/accept``
Owner: accept, which writes the `memberships` row via ``memberships.grant``
(the §22.8 "accepting writes the membership row"), then notifies the requester.
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/decline``
Owner: decline; the request closes and the requester is notified.
Mirrors ``api_contributions.py`` (the per-RFC contribute-request flow) but at the
scope grain: the target is a ``(scope_type, scope_id)`` pair drawn from the
``memberships`` scope vocabulary (minus ``global`` a deployment isn't a thing
one discovers and joins), and accept grants a scope role rather than minting an
RFC invitation.
The request POST deliberately does **not** require the scope be *readable*: the
whole point of request-to-join is to ask into a *gated* scope you were told about
but cannot see (§22.8). It is gated only on "you're signed in, granted, and not
already a member". Accept/decline are gated on Owner reach over the scope
(``auth.can_invite_at_project`` / ``auth.can_invite_at_collection``).
"""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import (
auth,
collections as collections_mod,
db,
memberships as memberships_mod,
notify,
)
_MESSAGE_MAX = 4000
class JoinRequestBody(BaseModel):
role: str
message: str | None = Field(default=None, max_length=_MESSAGE_MAX)
class DecideBody(BaseModel):
# On accept, the Owner may grant a role narrower than the one requested; a
# missing value grants exactly the requested role.
role: str | None = None
def _project_name(project_id: str) -> str | None:
row = db.conn().execute(
"SELECT name FROM projects WHERE id = ?", (project_id,)
).fetchone()
return row["name"] if row and row["name"] else None
def _resolve_scope(scope_type: str, scope_id: str) -> dict[str, Any]:
"""Resolve a `(scope_type, scope_id)` target to its display facts, or 404 if
it doesn't exist. Returns `{project_id, scope_name, project_name}`. The
`scope_type` itself must be one of the join-able scopes."""
if scope_type == "project":
row = db.conn().execute(
"SELECT id, name FROM projects WHERE id = ?", (scope_id,)
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
name = row["name"] or scope_id
return {"project_id": scope_id, "scope_name": name, "project_name": name}
if scope_type == "collection":
col = collections_mod.get_collection(scope_id)
if col is None:
raise HTTPException(404, "Not found")
pid = col["project_id"]
return {
"project_id": pid,
"scope_name": col.get("name") or scope_id,
"project_name": _project_name(pid),
}
raise HTTPException(404, "Not found")
def _require_join_owner(viewer, scope_type: str, scope_id: str) -> None:
"""The accept/decline gate: an Owner whose reach covers the scope (§22.8 'the
scope's Owners across the subtree'). Reuses the S4 invite gates."""
ok = (
auth.can_invite_at_collection(viewer, scope_id)
if scope_type == "collection"
else auth.can_invite_at_project(viewer, scope_id)
)
if not ok:
raise HTTPException(403, "Only an Owner of this scope can act on join requests")
def _require_request(scope_type: str, scope_id: str, request_id: int):
row = db.conn().execute(
"""
SELECT id, scope_type, scope_id, requester_user_id, requested_role,
message, status
FROM join_requests
WHERE id = ? AND scope_type = ? AND scope_id = ?
""",
(request_id, scope_type, scope_id),
).fetchone()
if row is None:
raise HTTPException(404, "Join request not found")
return row
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# GET — what the join form needs to render + gate itself.
# ---------------------------------------------------------------
@router.get("/api/scopes/{scope_type}/{scope_id}/join-target")
async def join_target(scope_type: str, scope_id: str, request: Request) -> dict[str, Any]:
facts = _resolve_scope(scope_type, scope_id)
viewer = auth.current_user(request)
eligible = True
reason: str | None = None
already_requested = False
current_role = auth.effective_role_at_scope(viewer, scope_type, scope_id)
if viewer is None:
eligible, reason = False, "Sign in to request to join."
elif viewer.permission_state != "granted":
eligible, reason = False, "Your beta access request is in review."
elif current_role is not None:
eligible, reason = False, f"You already hold {('Owner' if current_role == 'owner' else 'RFC Contributor')} here."
else:
already_requested = bool(
db.conn().execute(
"""
SELECT 1 FROM join_requests
WHERE scope_type = ? AND scope_id = ? AND requester_user_id = ?
AND status = 'pending' LIMIT 1
""",
(scope_type, scope_id, viewer.user_id),
).fetchone()
)
return {
"scope_type": scope_type,
"scope_id": scope_id,
"name": facts["scope_name"],
"project_id": facts["project_id"],
"eligible": eligible and not already_requested,
"reason": reason,
"already_requested": already_requested,
"current_role": current_role,
}
# ---------------------------------------------------------------
# POST — submit a request to join.
# ---------------------------------------------------------------
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests")
async def create_join_request(
scope_type: str, scope_id: str, body: JoinRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
facts = _resolve_scope(scope_type, scope_id)
role = (body.role or "").strip().lower()
if role not in memberships_mod.VALID_ROLES:
raise HTTPException(422, f"invalid role {body.role!r}")
# Already a member of the scope (at this or a broader grain)? Then there
# is nothing to request — a clear 409 rather than a useless self-request.
if auth.effective_role_at_scope(viewer, scope_type, scope_id) is not None:
raise HTTPException(409, "You already hold a role in this scope.")
message = (body.message or "").strip() or None
try:
cur = db.conn().execute(
"""
INSERT INTO join_requests
(scope_type, scope_id, requester_user_id, requested_role, message)
VALUES (?, ?, ?, ?, ?)
""",
(scope_type, scope_id, viewer.user_id, role, message),
)
except sqlite3.IntegrityError:
# The partial unique index — one open request per (scope, user).
raise HTTPException(409, "You already have a pending request to join this scope.")
request_id = cur.lastrowid
# One actionable notification per Owner across the subtree; stamp the
# first onto the row as the inbox-action handle (any Owner may act).
notif_ids = notify.fan_out_join_request(
scope_type=scope_type,
scope_id=scope_id,
scope_name=facts["scope_name"],
project_id=facts["project_id"],
project_name=facts["project_name"],
requester_user_id=viewer.user_id,
request_id=request_id,
requested_role=role,
message=message,
)
if notif_ids:
db.conn().execute(
"UPDATE join_requests SET notification_id = ? WHERE id = ?",
(notif_ids[0], request_id),
)
return {"id": request_id, "scope_type": scope_type, "scope_id": scope_id, "status": "pending"}
# ---------------------------------------------------------------
# POST — Owner accepts → write the membership row.
# ---------------------------------------------------------------
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/accept")
async def accept_join_request(
scope_type: str, scope_id: str, request_id: int, body: DecideBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
facts = _resolve_scope(scope_type, scope_id)
_require_join_owner(viewer, scope_type, scope_id)
req = _require_request(scope_type, scope_id, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
# The Owner may narrow the requested role on accept; default to what was
# asked for. (Both are within the Owner's grant reach at this scope.)
granted_role = (body.role or req["requested_role"] or "").strip().lower()
if granted_role not in memberships_mod.VALID_ROLES:
raise HTTPException(422, f"invalid role {body.role!r}")
memberships_mod.grant(
scope_type=scope_type,
scope_id=scope_id,
user_id=req["requester_user_id"],
role=granted_role,
granted_by=viewer.user_id,
)
db.conn().execute(
"""
UPDATE join_requests
SET status = 'accepted', decided_at = datetime('now'),
decided_by_user_id = ?, granted_role = ?
WHERE id = ?
""",
(viewer.user_id, granted_role, request_id),
)
notify.notify_join_decided(
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
scope_type=scope_type,
scope_id=scope_id,
scope_name=facts["scope_name"],
granted_role=granted_role,
accepted=True,
)
return {"ok": True, "status": "accepted", "granted_role": granted_role}
# ---------------------------------------------------------------
# POST — Owner declines.
# ---------------------------------------------------------------
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/decline")
async def decline_join_request(
scope_type: str, scope_id: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
facts = _resolve_scope(scope_type, scope_id)
_require_join_owner(viewer, scope_type, scope_id)
req = _require_request(scope_type, scope_id, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
db.conn().execute(
"""
UPDATE join_requests
SET status = 'declined', decided_at = datetime('now'),
decided_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, request_id),
)
notify.notify_join_decided(
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
scope_type=scope_type,
scope_id=scope_id,
scope_name=facts["scope_name"],
granted_role=None,
accepted=False,
)
return {"ok": True, "status": "declined"}
return router
+157
View File
@@ -0,0 +1,157 @@
"""§22 S4 (C.2) — the scope-role invitation surface.
An Owner grants `{owner, contributor}` at a scope their reach covers the
project, or a single collection within it to an existing account, looked up
by email. The grant writes a `memberships` row immediately and §15-notifies
the grantee (there is no accept round-trip; the C.2 scenarios name an existing
user and write the row directly). Endpoints:
GET /api/projects/:pid/members list the project subtree's grants
POST /api/projects/:pid/members grant at project scope, or
(with collection_id) at one collection
DELETE /api/projects/:pid/members/:user_id revoke (optionally ?collection_id=)
The single POST keys on the optional `collection_id` so the invite UI's one
control (role picker + scope picker) maps to one endpoint:
* no `collection_id` project-scope grant; gate `can_invite_at_project`.
* with `collection_id` collection-scope grant; gate `can_invite_at_collection`.
There is deliberately no "grant at parent, exclude a child" parameter (C.2.5):
the only knobs are role {owner, contributor} and scope {project, one
collection}. Reach is bounded by the inviter's own Owner reach (C.2.3): a
collection Owner who is nothing more is refused the project-scope POST.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from . import (
auth,
collections as collections_mod,
db,
memberships as memberships_mod,
notify,
)
class GrantBody(BaseModel):
email: str
role: str
collection_id: str | None = None
def _project_name(project_id: str) -> str | None:
row = db.conn().execute(
"SELECT name FROM projects WHERE id = ?", (project_id,)
).fetchone()
return row["name"] if row and row["name"] else None
def make_router() -> APIRouter:
router = APIRouter()
@router.get("/api/projects/{project_id}/members")
async def list_members(project_id: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
auth.require_project_readable(user, project_id)
# The full subtree listing is a project-Owner view; a collection-only
# Owner manages membership through the collection-scoped POST/DELETE.
if not auth.can_invite_at_project(user, project_id):
raise HTTPException(403, "You may not manage membership in this project")
return {"items": memberships_mod.list_for_project(project_id)}
@router.post("/api/projects/{project_id}/members")
async def grant_member(
project_id: str, body: GrantBody, request: Request
) -> dict[str, Any]:
user = auth.require_contributor(request)
auth.require_project_readable(user, project_id)
role = (body.role or "").strip().lower()
if role not in memberships_mod.VALID_ROLES:
raise HTTPException(422, f"invalid role {body.role!r}")
cid = (body.collection_id or "").strip() or None
if cid is not None:
# Collection-scope grant — bounded by Owner reach over that collection.
col = collections_mod.get_collection(cid)
if col is None or col["project_id"] != project_id:
raise HTTPException(404, "Not found")
if not auth.can_invite_at_collection(user, cid):
raise HTTPException(403, "You may not manage membership in this collection")
scope_type, scope_id = "collection", cid
else:
# Project-scope grant — bounded by Owner reach over the project.
if not auth.can_invite_at_project(user, project_id):
raise HTTPException(403, "You may not manage membership in this project")
scope_type, scope_id = "project", project_id
grantee = memberships_mod.user_by_email(body.email)
if grantee is None:
raise HTTPException(
404,
"No account with that email — the invitee must sign in to the "
"deployment before they can be granted a role",
)
memberships_mod.grant(
scope_type=scope_type,
scope_id=scope_id,
user_id=grantee["id"],
role=role,
granted_by=user.user_id,
)
# §15 (C.2): name the project and role to the grantee.
col_name = None
if scope_type == "collection":
col = collections_mod.get_collection(scope_id)
col_name = (col.get("name") if col else None) or scope_id
notify.notify_scope_role_granted(
recipient_user_id=grantee["id"],
granter_user_id=user.user_id,
scope_type=scope_type,
scope_id=scope_id,
role=role,
project_id=project_id,
project_name=_project_name(project_id),
collection_name=col_name,
)
return {
"scope_type": scope_type,
"scope_id": scope_id,
"user_id": grantee["id"],
"role": role,
"pending": grantee["permission_state"] != "granted",
}
@router.delete("/api/projects/{project_id}/members/{user_id}")
async def revoke_member(
project_id: str, user_id: int, request: Request
) -> dict[str, Any]:
user = auth.require_contributor(request)
auth.require_project_readable(user, project_id)
cid = (request.query_params.get("collection_id") or "").strip() or None
if cid is not None:
col = collections_mod.get_collection(cid)
if col is None or col["project_id"] != project_id:
raise HTTPException(404, "Not found")
if not auth.can_invite_at_collection(user, cid):
raise HTTPException(403, "You may not manage membership in this collection")
removed = memberships_mod.revoke(
scope_type="collection", scope_id=cid, user_id=user_id
)
else:
if not auth.can_invite_at_project(user, project_id):
raise HTTPException(403, "You may not manage membership in this project")
removed = memberships_mod.revoke(
scope_type="project", scope_id=project_id, user_id=user_id
)
return {"removed": removed}
return router
+210
View File
@@ -0,0 +1,210 @@
"""§22.4a SLICE-4/5 — entry metadata edit endpoints.
`POST .../rfcs/<slug>/meta` writes schema-defined metadata to an entry's sidecar
with a direct commit (D7: direct commit for authorized roles), validated against
the collection's field schema at the write boundary (INV-4), lazy-migrating a
legacy entry to a clean body-only `.md` on first edit. The Owner-gated
`metadata.migrate_collection` operator endpoint also lives here (SLICE-4 carried
work); SLICE-5's bulk endpoint will join it.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from . import (auth, cache, collections as collections_mod,
metadata as metadata_mod, metadata_schema,
projects as projects_mod)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
class MetaEditBody(BaseModel):
values: dict[str, Any]
class BulkMetaBody(BaseModel):
slugs: list[str]
op: str
field: str
value: Any = None
def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
"""Return the new value for `field` after applying `op` to `entry`.
`set` `value`; `add`/`remove` operate on the entry's current tags-list
value for `field` (the route restricts add/remove to tags-type fields).
"""
if op == "set":
return value
current = metadata_mod.metadata_dict(entry).get(field) or []
if not isinstance(current, list):
current = [current]
if op == "add":
return current if value in current else [*current, value]
if op == "remove":
return [x for x in current if x != value]
return value # unreachable; op validated by the route
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
router = APIRouter()
def _content_repo() -> tuple[str, str]:
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
def _md_path(collection_id: str, slug: str) -> str:
sub = collections_mod.subfolder_of(collection_id) or ""
rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
return f"{rfcs_dir}/{slug}.md"
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/meta")
async def edit_meta(
project_id: str, collection_id: str, slug: str,
body: MetaEditBody, request: Request,
) -> dict[str, Any]:
viewer = auth.current_user(request)
if collections_mod.project_of_collection(collection_id) != project_id:
raise HTTPException(404, "Collection not in project")
# INV-4: contributor+ on the collection (returns False for anonymous).
if not auth.can_contribute_in_collection(viewer, collection_id):
raise HTTPException(403, "Contributor access required to edit metadata")
col = collections_mod.get_collection(collection_id)
fields = (col or {}).get("fields") or {}
if not fields:
raise HTTPException(422, "Collection declares no editable fields")
if not body.values:
raise HTTPException(422, "Provide at least one field value")
unknown = [k for k in body.values if k not in fields]
if unknown:
raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}")
org, repo = _content_repo()
md_path = _md_path(collection_id, slug)
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
if st is None:
raise HTTPException(404, f"{md_path} not found")
# Validate the *raw* submitted values (INV-4): catch a type mismatch
# before `apply_values` coerces it — e.g. a scalar handed to a `tags`
# field would otherwise char-split into a valid-looking list.
problems = metadata_schema.validate(body.values, fields)
if problems:
raise HTTPException(422, {"problems": [p.as_dict() for p in problems]})
new_entry = metadata_mod.apply_values(st.entry, body.values)
files = metadata_mod.write_entry_files(md_path, new_entry, st)
try:
await bot.commit_entry_files(
viewer.as_actor(), org=org, repo=repo, files=files,
message=f"Edit metadata: {slug}", branch="main")
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_repo(config, gitea)
return {
"ok": True, "slug": slug,
"meta": metadata_mod.metadata_dict(new_entry),
}
@router.post("/api/projects/{project_id}/collections/{collection_id}/meta/bulk")
async def bulk_meta(
project_id: str, collection_id: str,
body: BulkMetaBody, request: Request,
) -> dict[str, Any]:
"""§22.4a PUC-2 (SLICE-5): apply one field op to many entries at once.
`set` works for any field; `add`/`remove` operate on a tags-type field.
Each passing entry's metadata is validated at the write boundary
(INV-4) and its sidecar staged; all stage into **one** commit (D7:
bulk = 1 commit, reusing the SLICE-4 sidecar write-through). Entries
that are missing or fail validation are reported in `rejected`; the
rest in `applied`. A no-op (value unchanged) is applied without writing.
"""
viewer = auth.current_user(request)
if collections_mod.project_of_collection(collection_id) != project_id:
raise HTTPException(404, "Collection not in project")
# INV-4: contributor+ on the collection (returns False for anonymous).
if not auth.can_contribute_in_collection(viewer, collection_id):
raise HTTPException(403, "Contributor access required to edit metadata")
col = collections_mod.get_collection(collection_id)
fields = (col or {}).get("fields") or {}
if not fields:
raise HTTPException(422, "Collection declares no editable fields")
if not body.slugs:
raise HTTPException(422, "Provide at least one entry")
if body.op not in ("set", "add", "remove"):
raise HTTPException(422, f"Unknown op: {body.op}")
if body.field not in fields:
raise HTTPException(422, f"Unknown field: {body.field}")
if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
raise HTTPException(422, f"op {body.op} requires a tags field")
org, repo = _content_repo()
applied: list[str] = []
rejected: list[dict[str, str]] = []
all_ops: list[dict[str, Any]] = []
for slug in body.slugs:
md_path = _md_path(collection_id, slug)
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
if st is None:
rejected.append({"slug": slug, "reason": "not found"})
continue
new_value = _apply_op(st.entry, body.op, body.field, body.value)
# Validate the *raw* new value before coercion (see edit_meta) so a
# scalar `set` onto a tags field is rejected, not char-split.
problems = metadata_schema.validate({body.field: new_value}, fields)
if problems:
rejected.append({"slug": slug,
"reason": "; ".join(p.message for p in problems)})
continue
applied.append(slug)
new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))
committed = False
if all_ops:
n = len(applied)
msg = f"Bulk {body.op} {body.field}: {n} entr{'y' if n == 1 else 'ies'}"
try:
await bot.commit_entry_files(
viewer.as_actor(), org=org, repo=repo, files=all_ops,
message=msg, branch="main")
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
committed = True
await cache.refresh_meta_repo(config, gitea)
return {"ok": True, "applied": applied,
"rejected": rejected, "committed": committed}
@router.post("/api/projects/{project_id}/collections/{collection_id}/migrate")
async def migrate(
project_id: str, collection_id: str, request: Request
) -> dict[str, Any]:
"""§22.4a PUC-5: migrate a collection's legacy-frontmatter entries to
clean body-only `.md` + sidecars, one commit per collection. Owner-gated
operator action. Safe to ship now that every entry write path is
sidecar-aware (SLICE-4 carried work). Idempotent."""
viewer = auth.current_user(request)
if collections_mod.project_of_collection(collection_id) != project_id:
raise HTTPException(404, "Collection not in project")
if not auth.is_collection_superuser(viewer, collection_id):
raise HTTPException(403, "Owner access required to migrate a collection")
org, repo = _content_repo()
subfolder = collections_mod.subfolder_of(collection_id) or ""
try:
result = await metadata_mod.migrate_collection(
gitea, org=org, repo=repo, subfolder=subfolder,
actor=viewer.as_actor())
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
if result["committed"]:
await cache.refresh_meta_repo(config, gitea)
return result
return router
+139 -16
View File
@@ -73,6 +73,13 @@ class MarkReadBody(BaseModel):
class BounceBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
kind: str = Field(default="hard") # 'hard' or 'complaint'
# v0.18.0 Slice 5: when the bounce provider includes the
# original Message-ID, the framework correlates it back to
# the matching `outbound_emails` row and stamps
# `status='bounced'`. Optional — providers that don't surface
# the Message-ID still flip the global opt-out via the email
# match, but lose the per-message attribution.
message_id: str | None = Field(default=None, max_length=1000)
class CookieConsentBody(BaseModel):
@@ -206,14 +213,16 @@ def make_router(config: Config) -> APIRouter:
@router.post("/api/rfcs/{slug}/watch")
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
rfc = db.conn().execute("SELECT (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(viewer, rfc["project_id"])
db.conn().execute(
"""
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
ON CONFLICT(user_id, rfc_slug) DO UPDATE SET
ON CONFLICT(collection_id, user_id, rfc_slug) DO UPDATE SET
state = excluded.state,
set_by = 'explicit',
set_at = excluded.set_at
@@ -443,6 +452,40 @@ def make_router(config: Config) -> APIRouter:
# ----- Email: one-click unsubscribe + bounce webhook -----
# v0.18.0: the category → column map. The `all` synthetic
# category lands the bundle's one-click on the global opt-out
# flag (per `email._send_bundle` in v0.18.0 Slice 2 — a bundle
# spans multiple categories, so a per-category flip wouldn't
# honor the user's intent).
_CATEGORY_COLUMN: dict[str, str] = {
"personal-direct": "email_personal_direct",
"structural": "email_watched_structural",
"admin-actionable": "email_admin_actionable",
"all": "email_opt_out_all",
}
def _apply_unsubscribe(user_id: int, category: str) -> bool:
"""Flip the matching column. Returns True on success, False
if the category is unknown. Idempotent running twice on
the same (user, category) is harmless (it sets the column
to its current value)."""
column = _CATEGORY_COLUMN.get(category)
if column is None:
return False
# `all` sets the flag to 1 (opt out); per-category sets to 0
# (turn that category off). The column semantic is "1 means
# don't send"; the per-category booleans are "1 means do
# send". Different polarities, hence the case split.
if category == "all":
db.conn().execute(
f"UPDATE users SET {column} = 1 WHERE id = ?", (user_id,)
)
else:
db.conn().execute(
f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,)
)
return True
@router.get("/api/email/unsubscribe")
async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse:
try:
@@ -453,20 +496,51 @@ def make_router(config: Config) -> APIRouter:
"<p>Open the app to manage your notification preferences directly.</p>",
status_code=400,
)
column = {
"personal-direct": "email_personal_direct",
"structural": "email_watched_structural",
"admin-actionable": "email_admin_actionable",
}.get(category)
if column is None:
if not _apply_unsubscribe(user_id, category):
return HTMLResponse(
f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
)
db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,))
return HTMLResponse(
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
f"You can re-enable them in your notification preferences.</p>"
)
if category == "all":
body = (
"<h1>Unsubscribed</h1><p>You will no longer receive any email "
"from this app. You can re-enable individual categories from "
"your notification preferences after signing in.</p>"
)
else:
body = (
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
f"You can re-enable them in your notification preferences.</p>"
)
return HTMLResponse(body)
@router.post("/api/email/unsubscribe")
async def email_unsubscribe_post(
request: Request,
t: str = Query(..., description="Signed token from the List-Unsubscribe header"),
) -> dict[str, Any]:
"""v0.18.0: RFC 8058 one-click endpoint.
Gmail and Yahoo POST `List-Unsubscribe=One-Click` (as a
form-encoded body) to the URL in the `List-Unsubscribe`
header when the user clicks their MUA's "Unsubscribe"
button. The endpoint MUST accept POST (per the
`List-Unsubscribe-Post` header we advertise) and MUST be
idempotent.
The body content is checked loosely RFC 8058 says it
SHOULD be exactly `List-Unsubscribe=One-Click`, but some
intermediaries strip / re-encode the body, so the
framework accepts any POST to the URL once the token
verifies. The bar is that the token signature carries the
authority; the body is hint-only.
"""
try:
user_id, category = email_mod.verify_unsubscribe_token(t)
except BadSignature:
raise HTTPException(400, "Invalid or expired token")
if not _apply_unsubscribe(user_id, category):
raise HTTPException(400, f"Unknown category: {category}")
return {"ok": True, "category": category}
@router.post("/api/webhooks/email-bounce")
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
@@ -485,21 +559,70 @@ def make_router(config: Config) -> APIRouter:
# stays unauthenticated for dev (the v1 contract).
import os as _os
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
if expected:
# v0.25.0 (audit 0026 M5): fail closed. An unset secret used to
# leave this endpoint fully unauthenticated — anyone could suppress
# any user's mail by POSTing their address (email_opt_out_all flip
# below). Now an unset secret DISABLES the endpoint (503) instead
# of opening it. A dev that genuinely wants it open opts in
# explicitly with RFC_APP_INSECURE_BOUNCE_WEBHOOK=1, mirroring the
# RFC_APP_INSECURE_WEBHOOKS dev-bypass on the Gitea hook.
if not expected:
if _os.environ.get("RFC_APP_INSECURE_BOUNCE_WEBHOOK", "").strip() == "1":
log.warning(
"email-bounce webhook running UNAUTHENTICATED "
"(RFC_APP_INSECURE_BOUNCE_WEBHOOK=1) — never set this in production"
)
else:
log.error(
"email-bounce webhook refused: WEBHOOK_EMAIL_BOUNCE_SECRET is unset "
"(set the secret to enable, or RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 for dev)"
)
raise HTTPException(503, "Bounce webhook not configured")
else:
received = request.headers.get("X-Webhook-Secret", "")
import hmac as _hmac
if not received or not _hmac.compare_digest(expected, received):
raise HTTPException(401, "Invalid webhook signature")
# v0.18.0 Slice 5: correlate the bounce back to the
# matching outbound_emails row if the provider supplied
# the Message-ID. The hard-bounce -> global-opt-out
# logic below still fires regardless; this is an
# additional audit signal.
correlated_row_id: int | None = None
if body.message_id:
correlated = db.conn().execute(
"SELECT id FROM outbound_emails WHERE message_id = ?",
(body.message_id,),
).fetchone()
if correlated is not None:
correlated_row_id = correlated["id"]
db.conn().execute(
"UPDATE outbound_emails SET status = 'bounced', "
"error = COALESCE(error, '') || ? WHERE id = ?",
(f"bounce ({body.kind})", correlated_row_id),
)
log.info(
"email-bounce: correlated message_id=%s -> outbound_emails.id=%s",
body.message_id, correlated_row_id,
)
else:
log.info(
"email-bounce: message_id=%s did not match any "
"outbound_emails row (provider may be replaying an old bounce, "
"or the row was pruned)",
body.message_id,
)
row = db.conn().execute(
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
).fetchone()
if row is None:
return {"ok": True, "matched": False}
return {"ok": True, "matched": False, "correlated_id": correlated_row_id}
db.conn().execute(
"UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
)
log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
return {"ok": True, "matched": True}
return {"ok": True, "matched": True, "correlated_id": correlated_row_id}
return router
+101 -38
View File
@@ -23,7 +23,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, metadata as metadata_mod, models_resolver, projects as projects_mod, rfc_links
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
class OpenPRBody(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(max_length=8000)
# Roadmap #26: optional "What will you be using this change for?" —
# the concrete ground-truth use case sibling to the required
# "why is this change needed" (the `description`). Optional, generous
# cap matching the description bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class PRDescriptionBody(BaseModel):
@@ -80,7 +85,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
if not _branch_has_commits_ahead(slug, branch):
@@ -123,7 +128,7 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
if branch == "main":
raise HTTPException(409, "PRs open from non-main branches")
owner, repo = _owner_repo(rfc)
@@ -148,7 +153,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, 1, 'just-me')
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
""",
(slug, branch),
)
@@ -173,6 +178,26 @@ def make_router(
raise HTTPException(502, f"Gitea: {e.detail}")
await _refresh_after_pr_write(rfc)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the PR number. Blank/omitted
# writes no row (absence == "left blank"). The mirror onto
# cached_prs keeps the cache column in parity.
use_case = (body.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?)
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
(use_case, slug, pr["number"]),
)
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
# -------------------------------------------------------------------
@@ -182,12 +207,18 @@ def make_router(
@router.get("/api/rfcs/{slug}/prs/{pr_number}")
async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
head_branch = pr_row["head_branch"]
# Roadmap #28 Part 1: build the RFC auto-link index once for this
# PR view (read-time enrichment against the live accepted-RFC
# corpus; see rfc_links.py). exclude_slug suppresses self-links to
# this RFC inside its own PR.
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
# §11.3: PRs are always public; no visibility check.
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
@@ -234,6 +265,13 @@ def make_router(
for r in msg_rows:
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
# Roadmap #28 Part 1: enrich every comment with RFC auto-link
# segments (read-time; see rfc_links.py). The description is
# enriched alongside it in the return dict below.
for _msgs in messages_by_thread.values():
for _m in _msgs:
_m["text_segments"] = link_index.segment(_m["text"])
# Per-user seen cursor per §10.3. Anonymous viewers get no
# cursor — they always see "everything new" but cannot advance
# the cursor (no row to write to).
@@ -300,6 +338,8 @@ def make_router(
"pr_number": pr_number,
"title": pr_row["title"],
"description": pr_row["description"],
"description_segments": link_index.segment(pr_row["description"]),
"proposed_use_case": _pr_use_case(pr_number),
"state": pr_row["state"],
"opened_by": pr_row["opened_by"],
"opened_at": pr_row["opened_at"],
@@ -333,7 +373,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/seen")
async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_active_rfc(slug, viewer)
_require_pr(slug, pr_number)
# Take the max of stored and incoming for both cursors so a
# stale tab firing a seen-cursor advance after a fresher tab
@@ -358,7 +398,7 @@ def make_router(
INSERT INTO pr_seen
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET
ON CONFLICT(collection_id, user_id, rfc_slug, pr_number) DO UPDATE SET
last_seen_commit_sha = excluded.last_seen_commit_sha,
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
@@ -380,7 +420,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/review")
async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
head_branch = pr_row["head_branch"]
cur = db.conn().execute(
@@ -407,7 +447,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/merge")
async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_merge(rfc, viewer):
raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge")
@@ -439,7 +479,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw")
async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_withdraw(rfc, pr_row, viewer):
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw")
@@ -470,7 +510,7 @@ def make_router(
slug: str, pr_number: int, body: PRDescriptionBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_edit_pr_text(rfc, pr_row, viewer):
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit")
@@ -495,7 +535,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch")
async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if pr_row["state"] != "open":
raise HTTPException(409, f"PR is {pr_row['state']}, not open")
@@ -563,7 +603,7 @@ def make_router(
repo=repo,
slug=slug,
file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc),
is_super_draft=_is_meta_resident(rfc),
original_branch=original_branch,
resolution_branch=resolution_branch,
)
@@ -621,42 +661,49 @@ def make_router(
# Helpers (closures over config/gitea/etc.)
# ------------------------------------------------------------------
def _require_rfc(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
# public" yields to a gated project: non-members get 404.
auth.require_project_readable(viewer, row["project_id"])
return row
def _require_active_rfc(slug: str):
def _require_active_rfc(slug: str, viewer):
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical."""
row = _require_rfc(slug)
surface is identical. Under the meta-only topology (§1) an active
RFC is meta-resident too (repo is null) that is normal, not an
error, so there is no per-RFC-repo precondition."""
row = _require_rfc(slug, viewer)
if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6). Drives the body/path/repo dispatch below."""
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc):
return config.gitea_org, config.meta_repo
if _is_meta_resident(rfc):
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str:
"""For super-draft entries the file on disk is the full
"""For meta-resident entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body."""
if not _is_super_draft(rfc):
if not _is_meta_resident(rfc):
return file_contents
try:
entry = entry_mod.parse(file_contents)
@@ -720,7 +767,7 @@ def make_router(
return row["original_pr_number"] if row else None
async def _refresh_after_pr_write(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
@@ -736,10 +783,10 @@ def make_router(
def _can_merge(rfc, viewer) -> bool:
"""§6.1 admin/owner OR §6.3 RFC owners/arbiters."""
"""§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -762,6 +809,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
return _can_withdraw(rfc, pr_row, viewer)
def _pr_use_case(pr_number: int) -> str | None:
"""Roadmap #26: the optional propose-PR use case from the canonical
side table, or None when the change was opened without one ("left
blank")."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
return {
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
@@ -951,20 +1009,25 @@ async def _replay_changes(
def _extract_body_for_replay(is_super_draft: bool, content: str) -> str:
# §22.4a SLICE-4: a meta-resident entry may be legacy (frontmatter+body) or
# migrated (body-only). strip_frontmatter handles both without raising.
if not is_super_draft:
return content
try:
return entry_mod.parse(content).body
except Exception:
return content
return metadata_mod.strip_frontmatter(content)
def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str:
# §22.4a SLICE-4: identity for body-only (migrated) files — never re-grow
# frontmatter; preserve a legacy file's frontmatter until its next metadata
# edit migrates it.
nb = new_body if new_body.endswith("\n") else new_body + "\n"
if not is_super_draft:
return new_body
entry = entry_mod.parse(prior_content)
entry.body = new_body if new_body.endswith("\n") else new_body + "\n"
return entry_mod.serialize(entry)
return nb
if entry_mod.FRONTMATTER_RE.match(prior_content):
entry = entry_mod.parse(prior_content)
entry.body = nb
return entry_mod.serialize(entry)
return nb
def _resolution_branch_name(original_branch: str) -> str:
+508 -15
View File
@@ -16,9 +16,11 @@ from typing import Any
import httpx
from fastapi import HTTPException, Request
from . import collections as collections_mod
from . import db
from .bot import Actor
from .config import Config
from .projects import DEFAULT_PROJECT_ID
@dataclass
@@ -290,6 +292,471 @@ def require_admin(request: Request) -> SessionUser:
return user
# ===========================================================================
# §22.6 / §22.7 — project-scoped authorization (the multi-project middle tier).
#
# A deployment hosts N projects (§22). Authorization for an action on an RFC is
# the *most-permissive union* of three tiers — the actor's deployment role
# (§6.1), their project role (§22.6), and their per-RFC authority (§6.3/§12) —
# with the §22.5 visibility gate and the §6.2 write-mute *subtractive* on top
# (§22.7). The per-RFC capability helpers below (`can_discuss_rfc`,
# `can_contribute_to_rfc`, `can_invite_to_rfc`) compose all three tiers, so the
# ~20 endpoint call sites inherit multi-project behavior unchanged.
#
# Through Slice M2 the only project is the migration-seeded `default` one (the
# N=1 case, §22.13); a project is resolved from an RFC slug via
# `cached_rfcs.project_id` (unique per slug while N=1). M3's registry mirror
# lets a deployment declare a second project; these gates already hold then.
#
# OPERATOR DECISIONS (M2):
# * implicit-on-public — on a `public` project a granted deployment
# `contributor` keeps the pre-multi-project write *baseline* (propose
# freely; an owned RFC's discuss/contribute is still gated by the v0.16.0
# per-RFC invite). No project_members row is needed and no backfill runs,
# so the N=1 case stays whole. Explicit project_members rows and
# gated/unlisted visibility are where the new tier actually bites.
# * preserve curation — the implicit-public baseline does NOT override per-RFC
# owner curation; only an *explicit* project_contributor/project_admin grant
# (or a deployment owner/admin) bypasses it. So §22.7's "project_contributor
# ⊇ rfc_collaborators(contributor)" holds for explicit grants, while a plain
# granted contributor on public behaves exactly as it did before M2.
# ===========================================================================
_DEPLOYMENT_SUPERUSER_ROLES = ("owner", "admin")
def project_visibility(project_id: str) -> str:
"""The project's §22.5 visibility ('gated' | 'public' | 'unlisted'). A
missing row reads as 'gated' the safe default: an unknown project is
invisible rather than open."""
row = db.conn().execute(
"SELECT visibility FROM projects WHERE id = ?", (project_id,)
).fetchone()
if row is None:
return "gated"
return row["visibility"] or "gated"
def _is_default_project(project_id: str) -> bool:
"""True iff `project_id` owns the migration-seeded `default` collection — the
deployment's primary project (§22.13), whatever its configured id. Only there
do M2's role rows (which migrated to collection scope `default`) stand in for
project-level authority."""
return collections_mod.project_of_collection(collections_mod.DEFAULT_COLLECTION_ID) == project_id
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
"""The user's *project-grain* §22.6 role at this project, or None — the
most-permissive of a **global** grant (inherits down to every project) and a
**project**-scope grant. Mapped back to the legacy
`project_admin`/`project_contributor` strings the project-grain authz speaks.
Back-compat: on the deployment's *default* project only, M2's rows live at
collection scope `default` (§B.3 migration), so a `default` collection-scope
grant there is read as project-level too. A collection grant on any other
project is NOT project authority that is the four-layer collection resolver
(`effective_scope_role`). Does not fold in the deployment tier
(`is_project_superuser` adds it) or the implicit-on-public baseline."""
if user is None:
return None
clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"]
params: list = [user.user_id, project_id]
if _is_default_project(project_id):
clauses.append("(scope_type = 'collection' AND scope_id = ?)")
params.append(collections_mod.DEFAULT_COLLECTION_ID)
row = db.conn().execute(
"SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") "
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
params,
).fetchone()
if row is None:
return None
return "project_admin" if row["role"] == "owner" else "project_contributor"
def project_of_rfc(rfc_slug: str) -> str:
"""The project an RFC belongs to, via its collection
(`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier).
Falls back to the default project when the slug isn't cached — the same N=1
default migration 026 backfills."""
row = db.conn().execute(
"SELECT c.project_id AS project_id "
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
"WHERE r.slug = ?",
(rfc_slug,),
).fetchone()
if row is None:
return DEFAULT_PROJECT_ID
return row["project_id"] or DEFAULT_PROJECT_ID
def collection_of_rfc(rfc_slug: str) -> str:
"""The collection an RFC belongs to (`cached_rfcs.collection_id`). Falls back
to the default collection when the slug isn't cached. Mirrors
`project_of_rfc`'s first-match semantics; a slug shared across collections is
a known routing ambiguity (the RFC-grain helpers take a bare slug) resolved
by the collection-qualified routes in later slices."""
row = db.conn().execute(
"SELECT collection_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
).fetchone()
if row is None or not row["collection_id"]:
return collections_mod.DEFAULT_COLLECTION_ID
return row["collection_id"]
def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
"""Maximal authority within a project: a deployment owner/admin (superuser
in every project, §22.7) or an explicit `project_admin` (§22.6). Both
subsume the per-RFC owners/arbiters tier."""
if user is None:
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
return project_member_role(user, project_id) == "project_admin"
def can_read_project(user: SessionUser | None, project_id: str) -> bool:
"""The §22.5 visibility gate at the project grain. `public`/`unlisted` are
readable by anyone (anonymous included `unlisted` is link-only but the link
still reads); `gated` is readable only by a deployment owner/admin or a
holder of any scope grant reaching the project a global grant, a project
grant, or membership at *any* collection within it (seeing a collection
implies seeing its project). Used as the subtractive read gate (a gated
project's entries 404 to non-members)."""
vis = project_visibility(project_id)
if vis in ("public", "unlisted"):
return True
# gated — scope-role holders + superusers only, subject to the §6 floor.
if user is None or user.permission_state != "granted":
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
row = db.conn().execute(
"SELECT 1 FROM memberships m WHERE m.user_id = ? AND ("
" m.scope_type = 'global'"
" OR (m.scope_type = 'project' AND m.scope_id = ?)"
" OR (m.scope_type = 'collection' AND m.scope_id IN "
" (SELECT id FROM collections WHERE project_id = ?))) LIMIT 1",
(user.user_id, project_id, project_id),
).fetchone()
return row is not None
def require_project_readable(user: SessionUser | None, project_id: str) -> None:
"""Raise 404 when the project is not readable by this viewer (§22.5: a
gated project is invisible to non-members indistinguishable from absent,
so the shape matches an unknown slug)."""
if not can_read_project(user, project_id):
raise HTTPException(status_code=404, detail="RFC not found")
def _has_write_baseline(user: SessionUser | None, project_id: str) -> bool:
"""The implicit-on-public write baseline: a granted deployment
`contributor` on a `public` project carries the pre-multi-project
write standing (still subject to per-RFC curation). Deployment
owner/admin are handled by `is_project_superuser`; on gated/unlisted a
contributor has no baseline and needs an explicit project role."""
if user is None or user.permission_state != "granted":
return False
return user.role == "contributor" and project_visibility(project_id) == "public"
def can_contribute_in_project(user: SessionUser | None, project_id: str) -> bool:
"""May the user contribute *new* content to the project (propose an entry)
the project-level (not RFC-specific) contribute standing. The union of
the override grants (superuser / explicit project_contributor) and the
implicit-public baseline, subject to the visibility gate."""
if user is None or user.permission_state != "granted":
return False
if not can_read_project(user, project_id):
return False
if is_project_superuser(user, project_id):
return True
if project_member_role(user, project_id) == "project_contributor":
return True
return _has_write_baseline(user, project_id)
def can_discuss_in_project(user: SessionUser | None, project_id: str) -> bool:
"""May the user participate in discussion in the project at all — the
project-level discuss standing (project_viewer discussant, §22.7).
A superset of `can_contribute_in_project` (a contributor can discuss)."""
if user is None or user.permission_state != "granted":
return False
if not can_read_project(user, project_id):
return False
if is_project_superuser(user, project_id):
return True
if project_member_role(user, project_id) in ("project_viewer", "project_contributor"):
return True
return _has_write_baseline(user, project_id)
def visible_project_ids(user: SessionUser | None) -> list[str]:
"""Project ids whose entries may surface in a listing for this viewer — the
§22.5 read gate applied to the catalog/idea lists. (The directory's
`unlisted`-omission and the per-project routing are M3 concerns; for the M2
listing filter we include every project the viewer can read.)"""
rows = db.conn().execute("SELECT id FROM projects").fetchall()
return [r["id"] for r in rows if can_read_project(user, r["id"])]
# ===========================================================================
# §22 three-tier — S3. The four-layer scope-role resolver (§B.2) and the
# collection-grain visibility gate.
#
# A grant attaches the unified role {owner, contributor} at a scope: global,
# project, or collection (§B.1). Grants inherit downward, are additive, and
# admit no negative override (§B.2). Effective authority over a *collection* is
# the most-permissive union of the layers reaching it:
#
# global (users.role owner/admin memberships scope_type='global')
# project (memberships scope_type='project' at the collection's project)
# collection (memberships scope_type='collection' at the collection)
#
# minus the §22.5 visibility gate and §6.2 write-mute (subtractive, as today).
# Per-entry authority (owners / arbiters / rfc_collaborators) is a distinct,
# finer layer the RFC-grain helpers union in beneath collection.
#
# OPERATOR DECISIONS (S3, session 0076):
# * scope-role-primary — a plain granted account (users.role 'contributor', no
# membership) is a granted *account*, not a write-everywhere global role.
# Write standing comes from an explicit scope grant; the lone exception is the
# grandfathered implicit-public baseline below.
# * grandfathered baseline — the migration-seeded `default` collection keeps the
# pre-three-tier implicit-on-public write baseline (a granted deployment
# contributor may propose while it is public), so the N=1 deployment (§22.13)
# loses no capability. Every *explicitly-created* collection requires an
# explicit scope grant to write.
# * hidden-from-public — a `gated` collection is invisible to the public (404,
# omitted from the directory) yet visible to any scope-role holder reaching it
# (collection/project/global). A collection's visibility may be set only as
# strict or stricter than its project's (the rank ordering below).
# ===========================================================================
# The global scope is a single tier per deployment; its grant rows use this
# sentinel scope_id (migration 030).
GLOBAL_SCOPE_ID = "*"
# §22.5 visibility strictness on the public-exposure axis: `public` is least
# strict, `gated` most strict. A collection may narrow its project's visibility
# but never widen it (`rank(collection) >= rank(project)`).
_VISIBILITY_RANK = {"public": 0, "unlisted": 1, "gated": 2}
def visibility_rank(visibility: str | None) -> int:
"""The strictness rank of a §22.5 visibility (higher = stricter). An unknown
value reads as the strictest (`gated`) the safe default."""
return _VISIBILITY_RANK.get(visibility or "", _VISIBILITY_RANK["gated"])
def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | None:
"""The most-permissive unified role ({'owner','contributor'}) the user holds
over `collection_id`, folding §B.2's global → project → collection layers.
Returns None when no scope grant reaches the collection. 'owner' outranks
'contributor'; there is no negative override (a parent grant is never
subtracted by a child). Subject to the §6 admission floor."""
if user is None or user.permission_state != "granted":
return None
# Global tier — a deployment owner/admin is a global Owner (§B.1).
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return "owner"
pid = collections_mod.project_of_collection(collection_id)
row = db.conn().execute(
"SELECT role FROM memberships "
"WHERE user_id = ? AND ("
" scope_type = 'global'"
" OR (scope_type = 'project' AND scope_id = ?)"
" OR (scope_type = 'collection' AND scope_id = ?)) "
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
(user.user_id, pid, collection_id),
).fetchone()
return row["role"] if row else None
def _effective_project_role(user: SessionUser | None, project_id: str) -> str | None:
"""The most-permissive role the user holds *over a project* — folding the
global tier (deployment owner/admin, or a `scope_type='global'` grant) and a
`scope_type='project'` grant on this project. Unlike `effective_scope_role`
(which keys on a collection), this answers the project grain directly, for the
§22.8 request-to-join membership check. Subject to the §6 admission floor."""
if user is None or user.permission_state != "granted":
return None
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return "owner"
row = db.conn().execute(
"SELECT role FROM memberships "
"WHERE user_id = ? AND ("
" scope_type = 'global'"
" OR (scope_type = 'project' AND scope_id = ?)) "
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
(user.user_id, project_id),
).fetchone()
return row["role"] if row else None
def effective_role_at_scope(
user: SessionUser | None, scope_type: str, scope_id: str
) -> str | None:
"""The most-permissive scope role the user holds over a `(scope_type,
scope_id)` target the scope-grain twin of `effective_scope_role`. A
`collection` target folds global project collection (the existing
resolver); a `project` target folds global project. Returns None when no
grant reaches the scope. Drives the §22.8 "already a member?" gate."""
if scope_type == "collection":
return effective_scope_role(user, scope_id)
if scope_type == "project":
return _effective_project_role(user, scope_id)
return None
def collection_visibility(collection_id: str) -> str:
"""The collection's own §22.5 visibility. A missing row reads as 'gated'
an unknown collection is invisible rather than open."""
row = db.conn().execute(
"SELECT visibility FROM collections WHERE id = ?", (collection_id,)
).fetchone()
if row is None or not row["visibility"]:
return "gated"
return row["visibility"]
def effective_collection_visibility(collection_id: str) -> str:
"""The stricter of the collection's own visibility and its project's (§22.5
'both gates'). A collection is constrained to be its project in strictness,
but we max() defensively so a misconfigured looser collection can never widen
its project's gate."""
cvis = collection_visibility(collection_id)
pid = collections_mod.project_of_collection(collection_id)
pvis = project_visibility(pid) if pid else "gated"
return cvis if visibility_rank(cvis) >= visibility_rank(pvis) else pvis
def can_read_collection(user: SessionUser | None, collection_id: str) -> bool:
"""§22.5 read/existence gate at the collection grain. `public`/`unlisted`
read by anyone (anonymous included `unlisted` is link-only but the link
reads); `gated` ("hidden from public existence") reads only for a scope-role
holder over the collection (collection/project/global) or a deployment
owner/admin. The subtractive read gate a gated collection 404s a
non-holder, indistinguishable from absent."""
vis = effective_collection_visibility(collection_id)
if vis in ("public", "unlisted"):
return True
if user is None or user.permission_state != "granted":
return False
return effective_scope_role(user, collection_id) is not None
def require_collection_readable(user: SessionUser | None, collection_id: str) -> None:
"""Raise 404 when the collection is not readable by this viewer (§22.5: a
hidden/gated collection is invisible to non-holders the shape matches an
unknown collection)."""
if not can_read_collection(user, collection_id):
raise HTTPException(status_code=404, detail="Not found")
def is_collection_superuser(user: SessionUser | None, collection_id: str) -> bool:
"""Maximal authority over a collection: an effective scope role of 'owner'
reaching it (a collection Owner, a project Owner of its project, a global
Owner, or a deployment owner/admin). Subsumes the per-entry owners/arbiters
tier within the collection."""
return effective_scope_role(user, collection_id) == "owner"
def _has_collection_write_baseline(user: SessionUser | None, collection_id: str) -> bool:
"""The grandfathered implicit-on-public write baseline, narrowed to the
migration-seeded `default` collection (§22.13 N=1 case). A granted deployment
`contributor` keeps its pre-three-tier write standing on the default
collection while its effective visibility is public; every explicitly-created
collection requires an explicit scope grant (S3 operator decision)."""
if user is None or user.permission_state != "granted":
return False
if collection_id != collections_mod.DEFAULT_COLLECTION_ID:
return False
return user.role == "contributor" and effective_collection_visibility(collection_id) == "public"
def can_contribute_in_collection(user: SessionUser | None, collection_id: str) -> bool:
"""May the user contribute *new* content to the collection (propose an entry)
the collection-level contribute standing. The union of the scope-role grant
(owner/contributor reaching the collection) and the grandfathered default
baseline, subject to the visibility read gate."""
if user is None or user.permission_state != "granted":
return False
if not can_read_collection(user, collection_id):
return False
if effective_scope_role(user, collection_id) is not None:
return True
return _has_collection_write_baseline(user, collection_id)
def can_discuss_in_collection(user: SessionUser | None, collection_id: str) -> bool:
"""May the user participate in discussion in the collection — the
collection-level discuss standing. A superset of contribute for this pass
(the read-only viewer tier is deferred, §B.3), so it mirrors
`can_contribute_in_collection`."""
return can_contribute_in_collection(user, collection_id)
def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
"""May the user create a new collection in this project (§B.1)? Creating a
collection is a *project-level* action: a deployment owner/admin, or any
holder of a project-scope or global-scope grant (Owner OR RFC Contributor
'anyone at the project level with permission to create a collection'). A
*collection*-scope grant cannot create sibling collections."""
if user is None or user.permission_state != "granted":
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
row = db.conn().execute(
"SELECT 1 FROM memberships "
"WHERE user_id = ? AND ("
" scope_type = 'global'"
" OR (scope_type = 'project' AND scope_id = ?)) LIMIT 1",
(user.user_id, project_id),
).fetchone()
return row is not None
def can_create_project(user: SessionUser | None) -> bool:
"""§22 S5 (§A.2 / §B.1): may the user create a new project? "+ New project"
is a **global-Owner** action a deployment owner/admin (a global Owner per
§B.1) or a holder of an explicit `scope_type='global'` Owner grant. Creating
a project is deployment-level, so it is not reachable by a project- or
collection-scope grant nor by a global RFC Contributor (that role creates
collections, not projects). Subject to the §6 admission floor."""
if user is None or user.permission_state != "granted":
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
row = db.conn().execute(
"SELECT 1 FROM memberships "
"WHERE user_id = ? AND scope_type = 'global' AND role = 'owner' LIMIT 1",
(user.user_id,),
).fetchone()
return row is not None
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any
collection within it)? Managing membership is an *Owner* capability whose
reach covers the project a deployment owner/admin, a global Owner, or this
project's Owner. An RFC Contributor does not manage membership (C.2.4); a
collection Owner's reach is its own collection only (C.2.3), so it is not
offered project-scope invites. Identical to `is_project_superuser` the
invite gate IS "is an Owner over this project"."""
return is_project_superuser(user, project_id)
def can_invite_at_collection(user: SessionUser | None, collection_id: str) -> bool:
"""§22 S4 (C.2): may the user grant scope roles at this collection? An Owner
whose reach covers it the collection's Owner, its project's Owner, a global
Owner, or a deployment owner/admin (`is_collection_superuser`). This is the
narrowest invite reach; a collection Owner who is nothing more may invite
here but not at the project or globally (C.2.3)."""
return is_collection_superuser(user, collection_id)
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
#
# These don't replace `require_contributor` — they layer on top of it for
@@ -386,18 +853,28 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
cid = collection_of_rfc(rfc_slug)
# §22.5 visibility gate is subtractive (§22.7) — no capability in a
# collection the viewer cannot even read.
if not can_read_collection(user, cid):
return False
# §B.2 union, scope-role grants first — these bypass per-RFC curation (a
# collection/project/global Owner or RFC Contributor ⊇ discussant).
if effective_scope_role(user, cid) is not None:
return True
# per-RFC authority (union term).
owners = _rfc_owners_set(rfc_slug)
if not owners:
# No owner to gate the invite-list — fall through to the
# platform-granted contract. The first §13.1 claim engages
# the gate; before that, anyone platform-granted can
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
return True
# grandfathered implicit-public baseline (curation preserved): on the default
# collection a granted deployment contributor may discuss only while the RFC
# is unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
# pre-multi-project v0.16.0 contract.
if not owners and _has_collection_write_baseline(user, cid):
return True
return False
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
@@ -419,16 +896,26 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
cid = collection_of_rfc(rfc_slug)
if not can_read_collection(user, cid):
return False
# §B.2 union, scope-role grants first (a collection/project/global RFC
# Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all).
if effective_scope_role(user, cid) is not None:
return True
# per-RFC authority (union term). A 'discussant' row is NOT sufficient —
# PRs are the higher-privilege surface.
owners = _rfc_owners_set(rfc_slug)
if not owners:
# Same fall-through as can_discuss_rfc: until an owner exists,
# the gate is open.
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
return True
# grandfathered implicit-public baseline (curation preserved): until an owner
# exists, a granted deployment contributor on the public default collection
# may contribute.
if not owners and _has_collection_write_baseline(user, cid):
return True
return False
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
@@ -439,7 +926,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
cid = collection_of_rfc(rfc_slug)
if not can_read_collection(user, cid):
return False
# An Owner reaching the collection (collection/project/global Owner, or a
# deployment owner/admin) may invite; otherwise only the RFC's frontmatter
# owner. Per-RFC collaborators and the baseline do not get the invite power.
if is_collection_superuser(user, cid):
return True
return is_rfc_owner(user, rfc_slug)
+315 -165
View File
@@ -27,7 +27,7 @@ import json
import logging
from dataclasses import dataclass
from . import db, notify
from . import db, entry as entry_mod, metadata as metadata_mod, notify
from .gitea import Gitea, GiteaError
log = logging.getLogger(__name__)
@@ -163,6 +163,117 @@ class Bot:
def __init__(self, gitea: Gitea):
self._gitea = gitea
# ----- Content repo: collection structure (§22 S2) -----
async def create_collection(
self,
actor: Actor,
*,
org: str,
content_repo: str,
collection_id: str,
manifest_yaml: str,
) -> dict:
"""§22 S2: commit `<collection_id>/.collection.yaml` to the content
repo's main. A structural admin action — committed straight to main (no
PR), like the registry config it feeds; the registry mirror then upserts
the collections row (§22.2 keeps the registry the source of truth). Logs
an audit row for the §6.5 trail."""
path = f"{collection_id}/.collection.yaml"
created = await self._gitea.create_file(
org,
content_repo,
path,
content=manifest_yaml,
message=_stamp_single(f"chore: create collection {collection_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
_log(
actor,
"create_collection",
bot_commit_sha=created.get("commit", {}).get("sha"),
details={"collection_id": collection_id, "repo": content_repo},
)
return created
async def create_project(
self,
actor: Actor,
*,
org: str,
registry_repo: str,
content_repo: str,
project_id: str,
projects_yaml_new: str,
projects_yaml_sha: str,
readme_text: str,
) -> dict:
"""§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping
a bot write at two git sources:
1. **provision the content repo** create `org/content_repo` if it
doesn't exist, then seed a `README.md` on `main` so the branch
exists (the contents API initialises the repo with that commit; the
corpus mirror and the propose path both need a `main` to write to).
2. **register the project** commit the caller-composed
`projects.yaml` (the existing doc with the new project appended) to
the registry repo's `main`.
Like `create_collection`, this is a structural admin action committed
straight to main (no PR), like the registry config it feeds; the caller
then re-runs the registry mirror so the new `projects` + default
`collections` rows flow from the registry (§22.2 keeps the registry the
source of truth). Logs a `create_project` audit row for the §6.5 trail.
Returns the registry update_file result (carries the new commit sha)."""
ae = actor.email or f"{actor.gitea_login}@users.noreply"
existing = await self._gitea.get_repo(org, content_repo)
if existing is None:
await self._gitea.create_org_repo(
org, content_repo, description=f"Content repo for project {project_id}"
)
# Seed a README if absent, which also establishes `main` on a freshly
# created (auto_init=False) repo — the contents API initialises the repo
# with that commit. Keyed on the README rather than the branch so it is
# idempotent and behaves identically whether the repo has a bare `main`
# or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed.
readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main")
if readme is None:
await self._gitea.create_file(
org,
content_repo,
"README.md",
content=readme_text,
message=_stamp_single(f"chore: initialise content repo for {project_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=ae,
)
result = await self._gitea.update_file(
org,
registry_repo,
"projects.yaml",
content=projects_yaml_new,
sha=projects_yaml_sha,
message=_stamp_single(f"chore: create project {project_id}", actor),
branch="main",
author_name=actor.display_name,
author_email=ae,
)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
_log(
actor,
"create_project",
bot_commit_sha=commit_sha,
details={"project_id": project_id, "content_repo": content_repo},
)
return result
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
async def open_idea_pr(
@@ -175,12 +286,16 @@ class Bot:
file_contents: str,
pr_title: str,
pr_description: str,
rfcs_dir: str = "rfcs",
) -> dict:
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
"""Per §9.1: open a meta-repo PR adding one file under `<rfcs_dir>/`.
One file per PR keeps idea submissions atomic and conflict-free.
The PR title and the file-add commit subject share §9.2's fixed
pattern; callers compose `pr_title` as `Propose: <Title>`.
pattern; callers compose `pr_title` as `Propose: <Title>`. §22 S2:
`rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a
propose into a named collection writes under its subfolder; it
defaults to `rfcs` (the default collection / shipped behaviour).
"""
branch = f"propose/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
@@ -189,7 +304,7 @@ class Bot:
created = await self._gitea.create_file(
org,
meta_repo,
f"rfcs/{slug}.md",
f"{rfcs_dir}/{slug}.md",
content=file_contents,
message=commit_message,
branch=branch,
@@ -289,6 +404,43 @@ class Bot:
pr_number=pr_number,
)
# ----- Entry sidecar writes (§22.4a SLICE-4) -----
async def commit_entry_files(
self, actor: Actor, *, org: str, repo: str,
files: list[dict], message: str, branch: str = "main",
) -> dict:
"""Commit a set of entry file ops (sidecar + body-only `.md`, from
`metadata.write_entry_files`) in one commit. Used by the direct-commit
metadata paths and, on a branch, by `open_entry_pr`."""
return await self._gitea.change_files(
org, repo, files=files,
message=_stamp_single(message, actor), branch=branch,
author_name=actor.display_name,
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
)
async def open_entry_pr(
self, actor: Actor, *, org: str, repo: str, slug: str,
files: list[dict], pr_title: str, pr_description: str,
branch_prefix: str = "metadata",
) -> dict:
"""Create a branch, commit entry file ops there, and open a PR — the
sidecar-aware successor to `open_metadata_pr`'s single-file write."""
import secrets
branch = f"{branch_prefix}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, repo, branch, from_branch="main")
await self.commit_entry_files(
actor, org=org, repo=repo, files=files,
message=pr_title, branch=branch)
_subject, pr_body = _stamp("", pr_description, actor)
pr = await self._gitea.create_pull(
org, repo, title=pr_title, body=pr_body, head=branch, base="main")
_log(actor, "open_entry_pr", rfc_slug=slug, branch_name=branch,
pr_number=pr["number"], details={"pr_title": pr_title})
return pr
# ----- Meta repo: metadata-pane PRs (§9.5) -----
async def open_metadata_pr(
@@ -695,111 +847,7 @@ class Bot:
)
return sha
# ----- §13 graduation: per-step primitives and rollback inverses -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id}{title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
# ----- §13 graduation (meta-only): open + merge the flip PR -----
async def open_graduation_pr(
self,
@@ -808,47 +856,45 @@ class Bot:
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
rfc_id: str,
repo_full: str,
files: list[dict],
rfc_id: str | None,
owners: list[str],
) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the
super-draft body and fills graduation frontmatter fields. Branch
name uses the `graduate-<slug>-<6hex>` shape dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry to `state: active` with the graduation stamps and **optionally**
the integer `id`, **keeping the body unchanged** (§1 meta-only
topology; no repo is created and no body is stripped). The graduation
metadata is written to the entry's sidecar (§22.4a) via `files`; a legacy
`.md` is lazy-migrated to body-only in the same commit. When `rfc_id` is
None the entry graduates without a number (id stays null, slug is
canonical per §2.3, §13.2). Branch name uses the `graduate-<slug>-<6hex>`
shape dash-separated like the other meta-repo branches per the §19.2
path-routing candidate.
"""
import secrets
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Graduate {slug}{rfc_id}"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
commit_subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=commit_subject, branch=branch)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"Graduate {slug}{rfc_id}"
pr_title = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
owners_str = ", ".join(owners) if owners else "(none)"
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"{id_line}"
f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
f"transactional per §13.3."
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
f"`id`, and the graduation stamps."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
@@ -862,7 +908,7 @@ class Bot:
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
details={"pr_title": pr_title, "rfc_id": rfc_id},
)
return pr
@@ -875,7 +921,7 @@ class Bot:
pr_number: int,
head_branch: str,
slug: str,
rfc_id: str,
rfc_id: str | None,
) -> None:
"""§13.3 step 4: auto-merge the graduation PR with the admin as
merge actor. Distinct action_kind so the audit log carries the
@@ -891,7 +937,7 @@ class Bot:
reports the transient state (belt-and-suspenders against the same
race appearing in a different shape).
"""
subject = f"Graduate {slug}{rfc_id}"
subject = f"Graduate {slug}{rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
body = _trailer(actor)
try:
@@ -912,27 +958,97 @@ class Bot:
details={"rfc_id": rfc_id},
)
# ----- §13.3 rollback inverses -----
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
async def delete_rfc_repo(
async def open_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
repo_name: str,
meta_repo: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
files: list[dict],
verb: str,
target_state: str,
) -> dict:
"""§13.7: open a PR flipping an entry to `state: <target_state>` — for
retire (`verb='retire'`, target `retired`) or un-retire
(`verb='unretire'`, target the restored prior state). The `state` change
is written to the entry's metadata sidecar (§22.4a), keeping the `.md`
body and every other field, so an un-retire restores the entry exactly.
`files` come from `metadata.write_entry_files`. Branch shape mirrors
graduation's `<verb>-<slug>-<6hex>`.
"""
import secrets
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
verb_title = "Retire" if verb == "retire" else "Un-retire"
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=f"{verb_title} {slug}", branch=branch)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
pr_title = f"{verb_title} {slug}"
pr_body_text = (
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
f"only `state` changes."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
org, meta_repo,
title=pr_title, body=pr_body, head=branch, base="main",
)
_log(
actor,
"graduate_repo_delete",
f"{verb}_pr_open",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "target_state": target_state},
)
return pr
async def merge_retire_flip_pr(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
pr_number: int,
head_branch: str,
slug: str,
verb: str,
) -> None:
"""§13.7: auto-merge the retire / un-retire flip PR (same
mergeable-wait + retry shape as `merge_graduation_pr`)."""
verb_title = "Retire" if verb == "retire" else "Un-retire"
subject = f"{verb_title} {slug}"
body = _trailer(actor)
try:
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
except TimeoutError as e:
raise GiteaError(409, str(e)) from e
await _merge_with_retry(
self._gitea, org, meta_repo, pr_number,
merge_message_title=subject, merge_message_body=body,
)
_log(
actor,
f"{verb}_pr_merge",
rfc_slug=slug,
branch_name=head_branch,
pr_number=pr_number,
details={},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr(
self,
@@ -1040,30 +1156,23 @@ class Bot:
org: str,
meta_repo: str,
slug: str,
new_file_contents: str,
prior_sha: str,
files: list[dict],
) -> dict:
"""§13.1: open a PR adding the actor to the entry's `owners:` list.
Touches only the frontmatter of `rfcs/<slug>.md`. Branch shape is
`claim/<slug>` single attempt per super-draft per actor (Gitea
refuses duplicate branch creation, which is the right behavior:
if the claim is still open, point the contributor at the existing
PR rather than opening a second one).
Writes the updated `owners:` to the entry's metadata sidecar (§22.4a)
via `files`; a legacy `.md` is lazy-migrated to body-only in the same
commit. Branch shape is `claim/<slug>` single attempt per super-draft
per actor (Gitea refuses duplicate branch creation, which is the right
behavior: if the claim is still open, point the contributor at the
existing PR rather than opening a second one).
"""
branch = f"claim/{slug}"
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
ae = actor.email or f"{actor.gitea_login}@users.noreply"
commit_subject = f"Claim ownership of {slug} for {actor.gitea_login}"
commit_message = _stamp_single(commit_subject, actor)
result = await self._gitea.update_file(
org, meta_repo, f"rfcs/{slug}.md",
content=new_file_contents,
sha=prior_sha,
message=commit_message,
branch=branch,
author_name=actor.display_name, author_email=ae,
)
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=commit_subject, branch=branch)
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
@@ -1090,6 +1199,47 @@ class Bot:
)
return pr
# ----- §22.4c: mark-reviewed (direct main write) -----
async def mark_entry_reviewed(
self,
actor: Actor,
*,
org: str,
meta_repo: str,
slug: str,
reviewed_by: str,
reviewed_at: str,
) -> None:
"""Clear §22.4c unreviewed on an active entry by writing its metadata
sidecar on main (§22.4a). Dual-reads the entry (so a migrated body-only
`.md` doesn't crash) and lazy-migrates a legacy `.md` to body-only in the
same commit. Stamps the §6.5 On-behalf-of trailer and writes an
actions-log row, mirroring the graduation stamp's bot-write shape."""
path = f"rfcs/{slug}.md"
st = await metadata_mod.read_entry_from_git(self._gitea, org, meta_repo, path)
if st is None:
raise GiteaError(404, f"{path} not found")
e = metadata_mod.apply_values(st.entry, {
"unreviewed": False, "reviewed_at": reviewed_at, "reviewed_by": reviewed_by,
})
files = metadata_mod.write_entry_files(path, e, st)
result = await self.commit_entry_files(
actor, org=org, repo=meta_repo, files=files,
message=f"Mark {slug} reviewed", branch="main")
commit_sha = (
result.get("commit", {}).get("sha")
or result.get("content", {}).get("sha")
or ""
)
_log(
actor,
"mark_reviewed",
rfc_slug=slug,
bot_commit_sha=commit_sha,
details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at},
)
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
async def ensure_rfc_repo_seed(
+201 -37
View File
@@ -27,7 +27,15 @@ import asyncio
import json
import logging
from . import db, entry as entry_mod
from . import (
collections as collections_mod,
db,
entry as entry_mod,
metadata as metadata_mod,
metadata_schema,
projects as projects_mod,
registry as registry_mod,
)
from .config import Config
from .gitea import Gitea, GiteaError
@@ -35,17 +43,63 @@ log = logging.getLogger(__name__)
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
"""Re-read rfcs/ on every project's content repo and reconcile cached_rfcs.
Idempotent. Safe to call on every meta-repo webhook and on every
reconciler sweep.
§22 (Plan B): a deployment has N projects (§22.1), each with its own
content_repo (§22.3). Mirror each into cached_rfcs stamped with that
project's id, so a second project's corpus renders under /p/<id>/. Idempotent;
safe on every content-repo webhook and reconciler sweep.
"""
org, repo = config.gitea_org, config.meta_repo
try:
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
except GiteaError as e:
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
org = config.gitea_org
rows = db.conn().execute(
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
).fetchall()
if not rows:
log.warning("refresh_meta_repo: no projects with a content_repo yet; skipping")
return
for prow in rows:
await _refresh_project_corpus(org, prow["id"], prow["content_repo"], gitea)
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
# §22 S2: the corpus grain is the collection. Mirror every collection of the
# project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the
# collection id. The default collection (subfolder '') reads `rfcs/` — the
# shipped path, unchanged. include_unlisted: the mirror serves every
# collection's content regardless of enumeration visibility.
from . import collections as collections_mod
for col in collections_mod.list_collections(project_id, include_unlisted=True):
await _refresh_collection_corpus(
org, project_id, repo, col["id"], col["subfolder"] or "", gitea
)
async def _refresh_collection_corpus(
org: str, project_id: str, repo: str, collection_id: str, subfolder: str, gitea: Gitea
) -> None:
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
try:
files = await gitea.list_dir(org, repo, rfcs_dir, ref="main")
except GiteaError as e:
log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s",
project_id, collection_id, rfcs_dir, e)
return
# §22.4a SLICE-2: a collection may declare a metadata field schema. Fetch it
# once for the whole corpus pass; entries whose stored values fail it are
# flagged malformed advisory-only (INV-3) — the read never hard-fails. A
# collection with no schema validates nothing (INV-5, the default unchanged).
col = collections_mod.get_collection(collection_id)
fields_schema = (col or {}).get("fields") or None
# §22.4a SLICE-1: an entry's metadata may live in a `<slug>.meta.yaml`
# sidecar (the source of truth) with the `.md` kept as pure prose. Map the
# sidecars surfaced by this listing so each `.md` can dual-read its sibling.
sidecar_path_by_slug = {
metadata_mod.slug_of_sidecar(f["name"]): f["path"]
for f in files
if f.get("type") == "file" and metadata_mod.is_sidecar(f.get("name", ""))
}
seen_slugs: set[str] = set()
for f in files:
@@ -55,41 +109,81 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
if not result:
continue
text, sha = result
stem = f["name"][:-len(".md")]
sidecar_text: str | None = None
sidecar_path = sidecar_path_by_slug.get(stem)
if sidecar_path:
sc_result = await gitea.read_file(org, repo, sidecar_path, ref="main")
sidecar_text = sc_result[0] if sc_result else None
try:
entry = entry_mod.parse(text)
entry, malformed = metadata_mod.read_entry(text, sidecar_text, fallback_slug=stem)
except Exception as parse_err:
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
project_id, collection_id, f["path"], parse_err)
continue
if not entry.slug:
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
project_id, collection_id, f["path"])
continue
if malformed:
log.warning("refresh_meta_repo: %s/%s: %s has malformed metadata sidecar",
project_id, collection_id, f["path"])
# §22.4a SLICE-2: advisory schema validation (INV-3). A schema violation
# flags the entry malformed without blocking the read, OR-ed onto any
# sidecar-syntax malformation above.
if fields_schema:
problems = metadata_schema.validate(
metadata_mod.metadata_dict(entry), fields_schema
)
if problems:
malformed = True
log.warning("refresh_meta_repo: %s/%s: %s fails its field schema: %s",
project_id, collection_id, f["path"],
"; ".join(p.message for p in problems))
seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha)
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id,
metadata_malformed=malformed)
# Mark entries removed from the meta repo as withdrawn-without-trace.
# In practice the spec keeps withdrawn entries in rfcs/ as historical
# record (§3), so this branch fires only for entries deleted out of
# band. We leave the row but flag it for reconciler attention.
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
# as historical record (§3), so this fires only for out-of-band deletes;
# leave the row, scoped to this collection, for reconciler attention.
existing = {
row["slug"]
for row in db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
)
}
for missing in existing - seen_slugs:
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row",
project_id, collection_id, missing)
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
def _upsert_cached_rfc(
entry: entry_mod.Entry,
body_sha: str,
collection_id: str = "default",
metadata_malformed: bool = False,
) -> None:
# §6.6: models_json stays NULL when the frontmatter key is absent
# (inherit operator universe) and '[]' for the explicit opt-out.
models_json = json.dumps(entry.models) if entry.models is not None else None
# §6.7: funder_login mirrors the optional `funder:` frontmatter
# field. NULL means absent — operator credentials are used.
funder_login = entry.funder or None
# §22.4a SLICE-3: persist the full per-entry metadata mapping (known keys +
# extra, never the body) so facet/filter can read any declared field. Stored
# via metadata_dict so the sidecar's forward-compat keys (INV-7) ride along.
meta_json = json.dumps(metadata_mod.metadata_dict(entry))
db.conn().execute(
"""
INSERT INTO cached_rfcs
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
models_json, funder_login, body, body_sha, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
models_json, funder_login, body, body_sha,
unreviewed, reviewed_at, reviewed_by, collection_id,
metadata_malformed, meta_json, last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(collection_id, slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
rfc_id = excluded.rfc_id,
@@ -105,6 +199,11 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
funder_login = excluded.funder_login,
body = excluded.body,
body_sha = excluded.body_sha,
unreviewed = excluded.unreviewed,
reviewed_at = excluded.reviewed_at,
reviewed_by = excluded.reviewed_by,
metadata_malformed = excluded.metadata_malformed,
meta_json = excluded.meta_json,
last_entry_commit_at = datetime('now'),
updated_at = datetime('now')
""",
@@ -125,6 +224,12 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
funder_login,
entry.body,
body_sha,
1 if entry.unreviewed else 0,
entry.reviewed_at,
entry.reviewed_by,
collection_id,
1 if metadata_malformed else 0,
meta_json,
),
)
@@ -184,7 +289,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -219,6 +324,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
open_pulls, closed_pulls = [], []
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
# the slug is known (param), so state still updates correctly and
# no ghost forms — but blindly storing the sentinel would clobber
# the real branch name api_prs.py relies on as a fallback ref when
# the merge commit is gone. Recover it from the stored row.
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor(
@@ -307,7 +425,11 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
structurally `edit/<slug>/<auto-name>` per §9.5, with dashes in place
of slashes per the §19.2 path-routing candidate.
"""
org, repo = config.gitea_org, config.meta_repo
org = config.gitea_org
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning("refresh_meta_branches: default project has no content_repo yet; skipping")
return
try:
branches = await gitea.list_branches(org, repo)
except GiteaError as e:
@@ -329,16 +451,20 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
if not slug:
continue
rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if not rfc or rfc["state"] != "super-draft":
# Meta-only topology (§1): edit branches live on the meta repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue
edit_keys_seen.add((slug, name))
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -352,14 +478,15 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
# diverges from this single point.
if meta_main_sha:
super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
"SELECT slug FROM cached_rfcs "
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
).fetchall()
for r in super_drafts:
db.conn().execute(
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at
""",
@@ -374,7 +501,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
SELECT b.rfc_slug, b.branch_name
FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft'
WHERE r.repo IS NULL
AND r.state IN ('super-draft', 'active')
AND b.state != 'deleted'
AND b.branch_name != 'main'
"""
@@ -418,19 +546,50 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
`On-behalf-of:` trailer from the PR body, then to the raw Gitea
login as last resort.
"""
org, repo = config.gitea_org, config.meta_repo
org = config.gitea_org
bot_login = config.gitea_bot_user
rows = db.conn().execute(
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
).fetchall()
if not rows:
log.warning("refresh_meta_pulls: no projects with a content_repo yet; skipping")
return
for prow in rows:
await _refresh_project_pulls(org, prow["id"], prow["content_repo"], gitea, bot_login)
async def _refresh_project_pulls(
org: str, project_id: str, repo: str, gitea: Gitea, bot_login: str
) -> None:
repo_full = f"{org}/{repo}"
try:
open_pulls = await gitea.list_pulls(org, repo, state="open")
closed_pulls = await gitea.list_pulls(org, repo, state="closed")
except GiteaError as e:
log.warning("refresh_meta_pulls: %s", e)
log.warning("refresh_meta_pulls: project %s: %s", project_id, e)
return
bot_login = config.gitea_bot_user
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# A merged-and-deleted PR's branch is no longer reported by Gitea
# as its real name — the `head.ref` collapses to the synthetic
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
# derive from the branch name, so a deleted branch would parse to
# slug=None and the row would be skipped forever, freezing the
# cached_prs row at its last-seen `state='open'` — a permanent
# ghost "pending idea" for an entry that has actually merged
# (caught when the operator authoring lane in ROADMAP #35 merged
# an idea PR with the branch deleted; the web UX leaves branches
# in place so it never tripped this). Recover the original branch
# from the row we already stored when the PR was open — that row
# retains the real `head_branch` (migration 002).
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
slug = _slug_from_head_branch(head_branch)
if slug is None:
continue
@@ -464,8 +623,8 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha, merge_commit_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
head_branch, base_branch, head_sha, merge_commit_sha, project_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title,
description = excluded.description,
@@ -492,6 +651,7 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
(pull.get("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"),
merge_commit_sha,
project_id,
),
)
@@ -625,6 +785,10 @@ class Reconciler:
async def sweep(self) -> None:
log.info("reconciler: starting sweep")
try:
try:
await registry_mod.refresh_registry(self._config, self._gitea)
except Exception:
log.exception("reconciler: registry refresh failed; keeping last-good projects")
await refresh_meta_repo(self._config, self._gitea)
await refresh_meta_branches(self._config, self._gitea)
await refresh_meta_pulls(self._config, self._gitea)
+146
View File
@@ -0,0 +1,146 @@
"""§22 collection grain — resolution helpers beneath the project tier.
In S1 each project has exactly one collection (the default). These helpers
recover the collection for a project and read the per-corpus fields (`type`,
`initial_state`) that moved down from `projects` in migration 029. Project-grain
authz (auth.py) recovers a row's project by joining `collections` on
`collection_id`.
"""
from __future__ import annotations
import json
from . import db
DEFAULT_COLLECTION_ID = "default"
# §22.4a item (2): the displayed noun for an entry is a type-driven label, a
# framework concept (like role names), not deployment content. The chrome reads
# this from the API rather than hardcoding "RFC", so a `specification` collection
# says "Spec" and a `bdd` collection says "Feature" with no per-deployment config.
ENTRY_NOUN = {
"document": "RFC",
"specification": "Spec",
"bdd": "Feature",
}
_DEFAULT_ENTRY_NOUN = "RFC"
def entry_noun(collection_type: str) -> str:
"""The §22.4a entry noun for a collection type. Unknown types fall back to
the generic 'RFC' so a future type is never label-less."""
return ENTRY_NOUN.get(collection_type, _DEFAULT_ENTRY_NOUN)
def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
"""§22.12 per-collection enabled_models from a `config_json` blob, or None
when unset (the collection inherits its project's universe)."""
if not config_json:
return None
try:
cfg = json.loads(config_json)
except (json.JSONDecodeError, TypeError):
return None
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
return [str(m) for m in em] if isinstance(em, list) else None
def _fields_from_config(config_json: str | None) -> dict | None:
"""§22.4a SLICE-2 per-collection metadata field schema from a `config_json`
blob, or None when the collection declares no `fields:`. The stored value is
already normalized by `metadata_schema.parse_fields` at ingest, so it's
served verbatim."""
if not config_json:
return None
try:
cfg = json.loads(config_json)
except (json.JSONDecodeError, TypeError):
return None
fields = cfg.get("fields") if isinstance(cfg, dict) else None
return fields if isinstance(fields, dict) and fields else None
def default_collection_id(project_id: str) -> str:
"""The id of a project's default (S1: sole) collection. Falls back to the
literal 'default' when the project has no collection row yet."""
row = db.conn().execute(
"SELECT id FROM collections WHERE project_id = ? ORDER BY created_at, id LIMIT 1",
(project_id,),
).fetchone()
return row["id"] if row else DEFAULT_COLLECTION_ID
def project_of_collection(collection_id: str) -> str | None:
"""The project a collection belongs to, or None if unknown."""
row = db.conn().execute(
"SELECT project_id FROM collections WHERE id = ?", (collection_id,)
).fetchone()
return row["project_id"] if row else None
def collection_initial_state(collection_id: str) -> str:
"""§22.4b landing state for new entries in a collection. 'super-draft'
default for an unknown/unset row (today's safe flow)."""
row = db.conn().execute(
"SELECT initial_state FROM collections WHERE id = ?", (collection_id,)
).fetchone()
if row is None or not row["initial_state"]:
return "super-draft"
return row["initial_state"]
def collection_type(collection_id: str) -> str:
"""The collection's immutable §22.4a type. 'document' default for unknown."""
row = db.conn().execute(
"SELECT type FROM collections WHERE id = ?", (collection_id,)
).fetchone()
return row["type"] if row and row["type"] else "document"
def subfolder_of(collection_id: str) -> str:
"""The content-repo subfolder a collection lives under (§22.3). Empty string
for the default collection (entries at the repo root `rfcs/`)."""
row = db.conn().execute(
"SELECT subfolder FROM collections WHERE id = ?", (collection_id,)
).fetchone()
return (row["subfolder"] if row else "") or ""
def get_collection(collection_id: str) -> dict | None:
"""The full collection row as a dict, or None if unknown. `enabled_models`
(§22.12) is unpacked from `config_json` as a list, or None when unset."""
row = db.conn().execute(
"SELECT id, project_id, type, subfolder, initial_state, visibility, name, "
"config_json FROM collections WHERE id = ?",
(collection_id,),
).fetchone()
if row is None:
return None
out = dict(row)
config_json = out.pop("config_json", None)
out["enabled_models"] = _enabled_models_from_config(config_json)
# §22.4a SLICE-2: serve the collection's metadata field schema (None when
# the collection declares no `fields:` — INV-5, the default `document`
# collection is unaffected).
out["fields"] = _fields_from_config(config_json)
out["entry_noun"] = entry_noun(out["type"])
return out
def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
"""Collections in a project, the default first then by name (§22.5). `unlisted`
is omitted from enumeration unless include_unlisted (a direct-id read or the
corpus mirror, which serves every collection)."""
rows = db.conn().execute(
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
"FROM collections WHERE project_id = ? ORDER BY (id != 'default'), name, id",
(project_id,),
).fetchall()
out: list[dict] = []
for r in rows:
if not include_unlisted and r["visibility"] == "unlisted":
continue
item = dict(r)
item["entry_noun"] = entry_noun(item["type"])
out.append(item)
return out
+21 -5
View File
@@ -32,7 +32,7 @@ class Config:
gitea_bot_user: str
gitea_bot_token: str
gitea_org: str
meta_repo: str
registry_repo: str
oauth_client_id: str
oauth_client_secret: str
app_url: str
@@ -44,14 +44,15 @@ class Config:
anthropic_api_key: str = ""
google_api_key: str = ""
openai_api_key: str = ""
default_project_id: str = ""
@property
def redirect_uri(self) -> str:
return f"{self.app_url}/auth/callback"
@property
def meta_repo_full(self) -> str:
return f"{self.gitea_org}/{self.meta_repo}"
def registry_repo_full(self) -> str:
return f"{self.gitea_org}/{self.registry_repo}"
def load_config() -> Config:
@@ -60,21 +61,36 @@ def load_config() -> Config:
enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()]
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory (per the
# email + webhook hygiene proposal). An empty value used to
# silently accept unsigned webhook POSTs — that was the
# invisible-failure shape the proposal targets. Now the
# framework refuses to start when the secret is empty unless
# the operator opts into the dev-bypass with
# `RFC_APP_INSECURE_WEBHOOKS=1`. Local-dev deployments without
# a wired Gitea hook set the bypass; production MUST NOT.
insecure_webhooks = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
if insecure_webhooks:
webhook_secret = _optional("GITEA_WEBHOOK_SECRET")
else:
webhook_secret = _required("GITEA_WEBHOOK_SECRET")
return Config(
gitea_url=_required("GITEA_URL").rstrip("/"),
gitea_bot_user=_required("GITEA_BOT_USER"),
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
gitea_org=_required("GITEA_ORG"),
meta_repo=_optional("META_REPO", "meta"),
registry_repo=_required("REGISTRY_REPO"),
oauth_client_id=_required("OAUTH_CLIENT_ID"),
oauth_client_secret=_required("OAUTH_CLIENT_SECRET"),
app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"),
secret_key=_required("SECRET_KEY"),
database_path=database_path,
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
webhook_secret=webhook_secret,
enabled_models=enabled,
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
google_api_key=_optional("GOOGLE_API_KEY"),
openai_api_key=_optional("OPENAI_API_KEY"),
default_project_id=_optional("DEFAULT_PROJECT_ID"),
)
+23 -1
View File
@@ -48,7 +48,29 @@ def run_migrations(config: Config) -> None:
if version in applied:
continue
sql = path.read_text()
conn.executescript("BEGIN; " + sql + "; COMMIT;")
if "-- migrate:no-foreign-keys" in sql:
# SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g.
# folding project_id into a composite key) follow the official
# 12-step ALTER procedure, which requires FK enforcement OFF —
# and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so
# it must be toggled here, around the script. The connection is
# in autocommit mode (isolation_level=None), so the PRAGMA takes
# effect immediately. We re-enable and run foreign_key_check
# after, failing the migration loudly if the rebuild left any
# dangling reference.
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
raise RuntimeError(
f"migration {version} left foreign-key violations: "
f"{[tuple(v) for v in violations]}"
)
finally:
conn.execute("PRAGMA foreign_keys = ON")
else:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
finally:
conn.close()
+32 -21
View File
@@ -97,6 +97,13 @@ class IssueOutcome:
raw_token: str
row_id: int
@property
def cookie_value(self) -> str:
"""The value to put in the `rfc_device_trust` cookie: the row-id
selector joined to the raw token (v0.25.0 / audit 0026 M1). The
selector lets `lookup` read one indexed row instead of scanning."""
return f"{self.row_id}.{self.raw_token}"
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
@@ -173,33 +180,37 @@ def lookup(raw_token: str) -> LookupOutcome:
if not raw:
return LookupOutcome(ok=False, user=None, reason="invalid")
# The unique index on `device_token_hash` would let us SELECT by
# hash if bcrypt were a stable hash, but bcrypt incorporates a
# per-row salt — equal tokens produce different hashes. We walk
# the candidate set instead. In practice the set is small (a
# human has a handful of trusted devices) and bcrypt is cheap on
# the order of milliseconds; the walk is bounded by the user's
# active device count.
# v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
# parse the row-id selector and read exactly ONE row by its indexed
# primary key, then bcrypt-check the token against that single row.
#
# We don't pre-filter by `revoked_at IS NULL` here so that a
# token presented for a recently-revoked row produces a
# 'revoked' outcome (the endpoint surfaces a different shape).
# Same for expired: we let the walk hit and classify after.
rows = db.conn().execute(
# The previous shape read EVERY device_trust row (all users, including
# revoked/expired) and bcrypt-checked each — an unauthenticated
# CPU-amplification DoS reachable at /auth/device-trust/start that
# grew without bound as the table accumulated. bcrypt's per-row salt
# is why we can't SELECT by hash; carrying the row-id in the cookie is
# the standard fix (the id is not secret; the token still is).
selector, sep, token = raw.partition(".")
if not sep or not selector.isdigit() or not token:
# Legacy bare-token cookies (pre-v0.25.0) and malformed values land
# here. We refuse rather than fall back to a full-table scan, so
# the amplification path is fully closed; affected users simply
# re-authenticate once via OTC/passcode and get a new cookie.
return LookupOutcome(ok=False, user=None, reason="invalid")
matched = db.conn().execute(
"""
SELECT id, user_id, device_token_hash, expires_at, revoked_at
FROM device_trust
ORDER BY id DESC
WHERE id = ?
""",
).fetchall()
(int(selector),),
).fetchone()
matched = None
for row in rows:
if _check(raw, row["device_token_hash"]):
matched = row
break
if matched is None:
# One bcrypt check, against the selected row only. A wrong/forged token
# for a real id reads as 'unknown' (cookie cleared), same as a missing
# row — a probing client can't distinguish the two.
if matched is None or not _check(token, matched["device_token_hash"]):
return LookupOutcome(ok=False, user=None, reason="unknown")
if matched["revoked_at"] is not None:
+13 -1
View File
@@ -180,7 +180,19 @@ def assemble_for_user(
subject = _subject(eligible, cadence)
body = _body(eligible, cadence, cfg)
sent = email_mod._deliver(cfg, email, subject, body)
# v0.18.0: the digest is the bulk-adjacent surface par excellence
# (it can carry weeks of accumulated activity), so it gets the
# full one-click unsubscribe to the global opt-out. Per-category
# opt-outs are managed from the preferences page; this footer is
# the "stop sending me anything" escape hatch Gmail and Yahoo
# expect for senders at this tier.
unsubscribe_url = email_mod.make_unsubscribe_url(user_id, "all")
sent = email_mod._deliver(
cfg, email, subject, body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="digest",
)
if not sent:
return False
ids = [r["id"] for r, _ in eligible]
+358
View File
@@ -0,0 +1,358 @@
"""§14 + roadmap item #30 — on-site sessions-history browser source.
Sibling of `docs.py` / `philosophy.py` but with a different read shape:
the bodies here live in the **public** `wiggleverse/ohm-session-history`
gitea repo (transcripts of every OHM build session, published per the
ohm-infra SESSION-PROTOCOL.md), not on disk. The framework mediates
the gitea fetch on behalf of the browser so the rendered `/docs/sessions/*`
surface inherits the same chrome as `/philosophy` and `/docs/user-guide`
and stays free of any cross-origin gestures from the frontend.
Three read endpoints, all anonymous-reachable:
GET /api/docs/sessions/manifest sessions.json (title manifest)
GET /api/docs/sessions/about README.md (the about page)
GET /api/docs/sessions/<NNNN>/<file> a transcript body
GET /api/docs/sessions/<NNNN>/index per-session file listing
All three sit behind a small in-process TTL cache (manifest TTL default
60 s, content TTL default 300 s). Negative results (404 from gitea) are
also cached at the content TTL to avoid hammering gitea when a
deployment hasn't yet been populated with transcripts. The cache key
is the URL path on the gitea raw base (or the contents API for the
per-session listing); the cache lives in-process, plain dict +
`time.monotonic()` check, no external dep.
Env knobs:
OHM_SESSION_HISTORY_RAW_BASE
Override the gitea raw base URL. Default points at OHM's canonical
transcript repo:
https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main
The framework-default value is OHM-flavored because OHM is the
only deployment to date a deployment running its own
transcript repo overrides this via flotilla's overlay.
OHM_SESSION_HISTORY_CONTENTS_BASE
Override the gitea contents-API base URL (for the per-session
listing endpoint, which enumerates files inside a `NNNN/` folder).
Default:
https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents
OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC
Cache TTL for the manifest (default 60 s). The manifest is small
and changes when a new session is added; 60 s strikes a balance
between freshness and gitea load.
OHM_DOCS_SESSIONS_CONTENT_TTL_SEC
Cache TTL for transcript bodies + README + per-session listings
(default 300 s = 5 minutes). Transcripts are append-only once
published, so 5 minutes of staleness is harmless.
§3 invariant 1 is preserved: the framework holds no secret bytes; the
gitea repo is public, the fetch carries no auth header.
"""
from __future__ import annotations
import logging
import os
import re
import threading
import time
from typing import Any
import httpx
log = logging.getLogger(__name__)
_DEFAULT_RAW_BASE = (
"https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main"
)
_DEFAULT_CONTENTS_BASE = (
"https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents"
)
_DEFAULT_MANIFEST_TTL_SEC = 60.0
_DEFAULT_CONTENT_TTL_SEC = 300.0
# The transcript filename shape per SESSION-PROTOCOL.md §1. The
# `<start>--<end>` suffix is optional so legacy renamed-letter
# transcripts (e.g. `SESSION-0009.0-TRANSCRIPT.md` without timestamps)
# remain reachable. The `\.\d+(\.\d+)*` after the session number
# accommodates `0017.0`, `0017.1`, `0017.1.1`, etc.
_TRANSCRIPT_FILENAME_RE = re.compile(
r"^SESSION-\d{4}\.\d+(\.\d+)*-TRANSCRIPT"
r"(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}--\d{4}-\d{2}-\d{2}T\d{2}-\d{2})?"
r"\.md$"
)
_SESSION_DIR_RE = re.compile(r"^\d{4}$")
_HTTP_TIMEOUT_SEC = 5.0
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return float(raw)
except ValueError:
log.warning("invalid %s=%r — falling back to %s", name, raw, default)
return default
def _raw_base() -> str:
return os.environ.get("OHM_SESSION_HISTORY_RAW_BASE", "").strip() or _DEFAULT_RAW_BASE
def _contents_base() -> str:
return (
os.environ.get("OHM_SESSION_HISTORY_CONTENTS_BASE", "").strip()
or _DEFAULT_CONTENTS_BASE
)
def _manifest_ttl() -> float:
return _env_float("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", _DEFAULT_MANIFEST_TTL_SEC)
def _content_ttl() -> float:
return _env_float("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
# ---------------------------------------------------------------------------
# In-process TTL cache
# ---------------------------------------------------------------------------
#
# Plain dict + `time.monotonic()` check, no external dep. The cache
# value is a `(stored_at, payload)` tuple; `payload` may carry an
# error-shape sentinel for negative caching (404s). Lock guards
# read-modify-write across worker tasks; entries are immutable once
# stored so reads under the lock are fast.
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict[str, Any]]] = {}
def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None:
with _lock:
entry = _cache.get(key)
if entry is None:
return None
stored_at, payload = entry
if time.monotonic() - stored_at > ttl_sec:
# Don't evict here; let _cache_put overwrite on next fetch.
# The stale entry is gated by the TTL check, so it stays
# invisible to readers regardless.
return None
return payload
def _cache_put(key: str, payload: dict[str, Any]) -> None:
with _lock:
_cache[key] = (time.monotonic(), payload)
def reset_cache() -> None:
"""Drop every cached entry. Test seam — not called in production."""
with _lock:
_cache.clear()
# ---------------------------------------------------------------------------
# Public fetch surface
# ---------------------------------------------------------------------------
#
# Each fetcher returns a `{status, ...}` dict. `status` is one of:
# "ok" — payload field carries the body
# "404" — gitea returned 404 (or content was missing)
# "error" — gitea returned 5xx, timed out, or returned malformed data
#
# The route layer maps these onto HTTP responses; keeping the mapping
# out of this module makes the cache transparent to the test harness.
async def _http_get(url: str) -> tuple[int, str]:
"""Perform a single GET against `url`; return (status_code, body).
On timeout or network error, returns (599, error_message). The 599
pseudo-status maps to a 502 at the route layer the same way an
upstream 5xx does the caller doesn't care which leg of the
network broke.
"""
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
r = await client.get(url)
return r.status_code, r.text
except httpx.HTTPError as e:
log.warning("gitea fetch failed for %s: %s", url, e)
return 599, f"fetch error: {e}"
def _is_valid_session_dir(nnnn: str) -> bool:
return bool(_SESSION_DIR_RE.match(nnnn))
def _is_valid_transcript_filename(filename: str) -> bool:
return bool(_TRANSCRIPT_FILENAME_RE.match(filename))
async def fetch_manifest() -> dict[str, Any]:
"""Fetch and parse `sessions.json` from the public repo.
Returns one of:
{"status": "ok", "manifest": {...}} successful parse
{"status": "404"} gitea 404 (empty state)
{"status": "error", "detail": "..."} 5xx / timeout / bad JSON
"""
cache_key = "manifest"
cached = _cache_get(cache_key, _manifest_ttl())
if cached is not None:
return cached
url = f"{_raw_base()}/sessions.json"
status, body = await _http_get(url)
if status == 200:
try:
import json
data = json.loads(body)
except (json.JSONDecodeError, ValueError) as e:
payload: dict[str, Any] = {
"status": "error",
"detail": f"sessions.json malformed: {e}",
}
# Don't cache parse errors — give the upstream a chance to
# fix the file without waiting for TTL expiry.
return payload
if not isinstance(data, dict):
return {
"status": "error",
"detail": "sessions.json is not a JSON object",
}
payload = {"status": "ok", "manifest": data}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
async def fetch_about() -> dict[str, Any]:
"""Fetch the repo's README.md (rendered as the /docs/sessions/about page).
Returns one of:
{"status": "ok", "body": "..."}
{"status": "404"}
{"status": "error", "detail": "..."}
"""
cache_key = "about:README.md"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
url = f"{_raw_base()}/README.md"
status, body = await _http_get(url)
if status == 200:
payload: dict[str, Any] = {"status": "ok", "body": body}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
async def fetch_transcript(nnnn: str, filename: str) -> dict[str, Any]:
"""Fetch a single transcript body from `{nnnn}/{filename}` in the repo.
The caller is expected to have validated `nnnn` and `filename`
against `_is_valid_session_dir` / `_is_valid_transcript_filename`
before calling this invalid paths shouldn't reach the network.
"""
cache_key = f"transcript:{nnnn}/{filename}"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
url = f"{_raw_base()}/{nnnn}/{filename}"
status, body = await _http_get(url)
if status == 200:
payload: dict[str, Any] = {"status": "ok", "body": body}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
async def fetch_session_index(nnnn: str) -> dict[str, Any]:
"""List the transcript filenames inside the `{nnnn}/` folder.
Uses gitea's contents API (one HTTP per session-index page-view per
cache-TTL) rather than the raw URL there's no flat way to list a
folder via the raw mount.
Returns one of:
{"status": "ok", "files": ["SESSION-...md", ...]}
{"status": "404"}
{"status": "error", "detail": "..."}
Only filenames that match `_is_valid_transcript_filename` are
surfaced sibling files (e.g. an attached `notes.md`) are ignored
so the /docs/sessions/<NNNN> page never lists a non-transcript
masquerading as one.
"""
cache_key = f"index:{nnnn}"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
url = f"{_contents_base()}/{nnnn}"
status, body = await _http_get(url)
if status == 200:
try:
import json
data = json.loads(body)
except (json.JSONDecodeError, ValueError) as e:
return {
"status": "error",
"detail": f"contents API response malformed: {e}",
}
if not isinstance(data, list):
return {
"status": "error",
"detail": "contents API returned non-list",
}
files: list[str] = []
for entry in data:
if not isinstance(entry, dict):
continue
if entry.get("type") != "file":
continue
name = entry.get("name")
if not isinstance(name, str):
continue
if _is_valid_transcript_filename(name):
files.append(name)
files.sort()
payload: dict[str, Any] = {"status": "ok", "files": files}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
+326
View File
@@ -0,0 +1,326 @@
"""v0.20.0 — on-site framework-specs surface source.
Sibling of `docs_sessions.py` (v0.19.0 / roadmap item #30): the
framework mediates a gitea fetch on behalf of the browser so the
rendered `/docs/specs/*` surface inherits the same chrome as
`/docs/user-guide` and `/docs/sessions/*` and stays free of any
cross-origin gestures from the frontend.
Two read endpoints, both anonymous-reachable:
GET /api/docs/specs/manifest the configured spec list
GET /api/docs/specs/<name> a single spec body (markdown)
The framework-default manifest is OHM-flavored (rfc-app's own SPEC.md
+ flotilla's SPEC.md on `git.wiggleverse.org`) for the same reason
`docs_sessions.py`'s defaults are: OHM is the only deployment to
date. A deployment running its own spec set overrides the manifest
via the `OHM_DOCS_SPECS` env var (set through flotilla's overlay).
History is intentionally not surfaced here the operator-stated
intent is "current version only; git is the history surface".
Per-spec entries carry three fields:
name URL-safe slug (`[a-z0-9-]+`) the path segment
title human-readable label shown in the nav and the page header
url the upstream raw URL the framework fetches
Validation:
- The configured list must be a JSON array of `{name, title, url}`
objects. A malformed `OHM_DOCS_SPECS` value (bad JSON, wrong
shape, invalid slug) logs a warning and falls back to the default
so a typo in the overlay doesn't crash startup.
- Each `name` is checked against `^[a-z0-9-]+$` before the manifest
is accepted. The route layer also validates the path-bound `name`
parameter before any network call, so a malformed URL never
reaches the cache or the upstream.
Cache shape mirrors `docs_sessions.py`: in-process `dict` + monotonic
TTL check, negative results (404) cached, no external dep. The
manifest is cheap (parsed from an env var, no network), so it has no
TTL every request re-derives it. Per-spec content has a 5-minute
default TTL (env-tunable via `OHM_DOCS_SPECS_CONTENT_TTL_SEC`).
§3 invariant 1 is preserved: the framework holds no secret bytes;
the upstream specs are public-repo raw URLs, the fetch carries no
auth header.
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
from typing import Any
import httpx
log = logging.getLogger(__name__)
# The framework-default spec set. OHM-flavored per the same precedent
# `docs_sessions.py` set: the only live deployment is OHM, so the
# default points there. A deployment running its own specs overrides
# `OHM_DOCS_SPECS` via the overlay.
_DEFAULT_SPECS: list[dict[str, str]] = [
{
"name": "rfc-app",
"title": "rfc-app SPEC",
"url": (
"https://git.wiggleverse.org/ben.stull/rfc-app/"
"raw/branch/main/SPEC.md"
),
},
{
"name": "flotilla",
"title": "flotilla SPEC",
"url": (
"https://git.wiggleverse.org/wiggleverse/ohm-rfc-app-flotilla/"
"raw/branch/main/SPEC.md"
),
},
]
_DEFAULT_CONTENT_TTL_SEC = 300.0
# URL-safe slug. Matches `docs_sessions.py`'s `_SESSION_DIR_RE` spirit
# (rejecting anything that could resolve outside the intended layout)
# but with the lowercase-alphanumeric-plus-dash shape the manifest
# enforces. Path traversal (`..`), separators (`/`), tilde, uppercase,
# and whitespace all fail this regex; the route layer rejects 400
# before any cache or network call.
_NAME_RE = re.compile(r"^[a-z0-9-]+$")
_HTTP_TIMEOUT_SEC = 5.0
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return float(raw)
except ValueError:
log.warning("invalid %s=%r — falling back to %s", name, raw, default)
return default
def _content_ttl() -> float:
return _env_float("OHM_DOCS_SPECS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
def _is_valid_name(name: str) -> bool:
"""Slug guard for path-bound `name` parameters.
Mirrors `docs_sessions._is_valid_session_dir`'s contract: the
route layer calls this before any network or cache work, so a
malformed name never escapes the FastAPI surface.
"""
return bool(isinstance(name, str) and _NAME_RE.match(name))
def _parse_spec_entry(entry: Any) -> dict[str, str] | None:
"""Validate a single manifest entry; return None if invalid.
Required fields: `name`, `title`, `url`. All three must be
non-empty strings; `name` must match `_NAME_RE`. The validator is
strict: an entry that fails any check is dropped from the manifest
(and the caller logs at warning level).
"""
if not isinstance(entry, dict):
return None
name = entry.get("name")
title = entry.get("title")
url = entry.get("url")
if not isinstance(name, str) or not _is_valid_name(name):
return None
if not isinstance(title, str) or not title.strip():
return None
if not isinstance(url, str) or not url.strip():
return None
return {"name": name, "title": title.strip(), "url": url.strip()}
def _load_configured_specs() -> list[dict[str, str]]:
"""Parse `OHM_DOCS_SPECS` (if set) or return the default list.
Malformed JSON or wrong-shape values log a warning and fall back
to the default the deployment continues to render the spec
surface rather than crashing startup. The strict validation (each
entry's name slug, presence of all three fields) drops bad entries
one-by-one; if every entry is dropped, the default applies.
"""
raw = os.environ.get("OHM_DOCS_SPECS", "").strip()
if not raw:
return list(_DEFAULT_SPECS)
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, ValueError) as e:
log.warning(
"OHM_DOCS_SPECS is not valid JSON (%s) — falling back to default", e
)
return list(_DEFAULT_SPECS)
if not isinstance(parsed, list):
log.warning(
"OHM_DOCS_SPECS must be a JSON array — falling back to default"
)
return list(_DEFAULT_SPECS)
out: list[dict[str, str]] = []
seen: set[str] = set()
for entry in parsed:
validated = _parse_spec_entry(entry)
if validated is None:
log.warning(
"OHM_DOCS_SPECS entry %r failed validation — dropped", entry
)
continue
if validated["name"] in seen:
log.warning(
"OHM_DOCS_SPECS has duplicate name %r — dropped", validated["name"]
)
continue
seen.add(validated["name"])
out.append(validated)
if not out:
log.warning(
"OHM_DOCS_SPECS yielded no valid entries — falling back to default"
)
return list(_DEFAULT_SPECS)
return out
# ---------------------------------------------------------------------------
# In-process TTL cache
# ---------------------------------------------------------------------------
#
# Same shape as `docs_sessions.py`: plain dict + `time.monotonic()` check,
# no external dep. The cache value is a `(stored_at, payload)` tuple;
# `payload` may carry an error-shape sentinel for negative caching (404s).
# Lock guards read-modify-write across worker tasks; entries are immutable
# once stored so reads under the lock are fast.
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict[str, Any]]] = {}
def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None:
with _lock:
entry = _cache.get(key)
if entry is None:
return None
stored_at, payload = entry
if time.monotonic() - stored_at > ttl_sec:
return None
return payload
def _cache_put(key: str, payload: dict[str, Any]) -> None:
with _lock:
_cache[key] = (time.monotonic(), payload)
def reset_cache() -> None:
"""Drop every cached entry. Test seam — not called in production."""
with _lock:
_cache.clear()
# ---------------------------------------------------------------------------
# Public fetch surface
# ---------------------------------------------------------------------------
#
# Each fetcher returns a `{status, ...}` dict, same convention as
# `docs_sessions.py`:
# "ok" — payload field carries the body / manifest
# "404" — gitea returned 404 (or the configured name doesn't exist)
# "error" — gitea returned 5xx, timed out, or returned malformed data
#
# The route layer maps these onto HTTP responses; keeping the mapping
# out of this module makes the cache transparent to the test harness.
async def _http_get(url: str) -> tuple[int, str]:
"""Perform a single GET against `url`; return (status_code, body).
On timeout or network error, returns (599, error_message). The 599
pseudo-status maps to a 502 at the route layer the same way an
upstream 5xx does the caller doesn't care which leg of the
network broke.
"""
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
r = await client.get(url)
return r.status_code, r.text
except httpx.HTTPError as e:
log.warning("specs fetch failed for %s: %s", url, e)
return 599, f"fetch error: {e}"
def fetch_specs_manifest() -> dict[str, Any]:
"""Return the configured spec manifest.
The manifest is derived from the `OHM_DOCS_SPECS` env var (or the
framework default if unset / malformed) and carries no network
work it's safe to call on every request. The return shape mirrors
the docs_sessions manifest endpoint for frontend consistency:
{"status": "ok", "specs": [{"name", "title", "url"}, ...]}
The "url" field is exposed in the manifest so the frontend can
offer a "view source on gitea" affordance alongside each rendered
spec (operator-stated intent: "include the history so you can see
it in git" — that gesture lives in the source link, not on the
rendered page).
"""
specs = _load_configured_specs()
return {"status": "ok", "specs": specs}
async def fetch_spec(name: str) -> dict[str, Any]:
"""Fetch a single spec body by its manifest `name`.
The caller is expected to have validated `name` against
`_is_valid_name` before calling this an invalid name shouldn't
reach the network. We re-check inside as defense-in-depth: a
bogus name here returns the same `{status: "404"}` shape so the
route layer's `404 → HTTP 404` mapping handles it uniformly.
Returns one of:
{"status": "ok", "body": "..."}
{"status": "404"} no such spec OR upstream 404
{"status": "error", "detail": "..."} upstream 5xx / timeout
"""
if not _is_valid_name(name):
return {"status": "404"}
cache_key = f"spec:{name}"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
specs = _load_configured_specs()
match = next((s for s in specs if s["name"] == name), None)
if match is None:
# Cache the negative — a deployment with an unstable manifest
# would still benefit from the TTL window, and the cached 404
# is automatically displaced when the next request happens
# after TTL expiry.
payload: dict[str, Any] = {"status": "404"}
_cache_put(cache_key, payload)
return payload
url = match["url"]
status, body = await _http_get(url)
if status == 200:
payload = {"status": "ok", "body": body}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
+165 -10
View File
@@ -24,7 +24,6 @@ import os
import smtplib
from dataclasses import dataclass
from datetime import datetime, time, timezone
from email.message import EmailMessage
from email.utils import formataddr
from itertools import groupby
from typing import Any
@@ -33,6 +32,7 @@ from urllib.parse import urlencode
from itsdangerous import BadSignature, URLSafeSerializer
from . import db
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -69,6 +69,7 @@ class EmailConfig:
app_url: str
bundle_threshold: int
enabled: bool
unsubscribe_mailto: str
@classmethod
def from_env(cls) -> "EmailConfig":
@@ -84,6 +85,16 @@ class EmailConfig:
app_url=os.environ.get("APP_URL", "http://localhost:8000").rstrip("/"),
bundle_threshold=int(os.environ.get("EMAIL_BUNDLE_THRESHOLD", "5")),
enabled=os.environ.get("EMAIL_ENABLED", "1") not in ("0", "false", "False"),
# v0.18.0: the `List-Unsubscribe: <mailto:…>` target on
# invite + notification mail. Defaults to the From
# address when unset; a deployment can route opt-out
# mail to a separate mailbox (e.g., a humans-monitored
# account distinct from the no-reply notifications
# sender) by setting this explicitly.
unsubscribe_mailto=os.environ.get(
"EMAIL_UNSUBSCRIBE_MAILTO",
os.environ.get("EMAIL_FROM", "notifications@wiggleverse.local"),
).strip(),
)
@@ -98,6 +109,14 @@ def _signer() -> URLSafeSerializer:
def make_unsubscribe_url(user_id: int, category: str) -> str:
"""Build the §15.4 per-category one-click URL.
`category` is one of `personal-direct`, `structural`,
`admin-actionable` (the three per-category flags) or `all`
(v0.18.0: the bundle path, which sets `email_opt_out_all = 1`
because a bundle covers multiple categories and a per-category
opt-out wouldn't honor the user's intent).
"""
cfg = EmailConfig.from_env()
token = _signer().dumps({"u": user_id, "c": category})
qs = urlencode({"t": token})
@@ -250,7 +269,21 @@ def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
return
subject = _subject(payload)
body = _body(payload, user["id"], category, cfg)
sent = _deliver(cfg, user["email"], subject, body)
# v0.18.0: notification mail is bulk-adjacent (a watcher can
# accumulate dozens of structural events on a busy RFC), so it
# carries the full one-click unsubscribe — Gmail and Yahoo
# require this for senders at OHM's volume tier per RFC 8058.
unsubscribe_url = make_unsubscribe_url(user["id"], category)
sent = _deliver(
cfg,
user["email"],
subject,
body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="notification",
notification_id=notif_id,
)
if not sent:
return
db.conn().execute(
@@ -305,23 +338,65 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
return cfg.app_url
def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool:
def _deliver(
cfg: EmailConfig,
to_address: str,
subject: str,
body: str,
*,
unsubscribe_mailto: str | None = None,
unsubscribe_url: str | None = None,
kind: str = "notification",
notification_id: int | None = None,
) -> bool:
"""Build the envelope via the shared `build_envelope` helper and
hand it to SMTP.
The `_SENT` buffer carries the helper's `EmailMessage` under
`message` plus the legacy `to`/`from`/`subject`/`body` keys for
backward-compatibility with tests that read those directly.
Newer tests can assert on the header surface by inspecting
`envelope["message"]`.
v0.18.0 Slice 4: also writes one row to `outbound_emails`
capturing the attempt. status='sent' on success, 'failed' on
SMTP exception, 'deferred' on the dev-fallback path (no
SMTP_HOST configured the send didn't happen, but the row
records the attempt so the admin endpoint can answer "did the
framework try?").
"""
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
unsubscribe_mailto=unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"message": msg,
"kind": kind,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.smtp_host:
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="deferred",
message_id=message_id,
notification_id=notification_id,
)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
@@ -331,12 +406,78 @@ def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="sent",
message_id=message_id,
notification_id=notification_id,
)
return True
except Exception:
except Exception as exc:
log.exception("email send failed: to=%s subject=%s", to_address, subject)
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="failed",
error=f"{type(exc).__name__}: {exc}",
message_id=message_id,
notification_id=notification_id,
)
return False
def record_outbound(
*,
to_address: str,
from_address: str,
subject: str,
kind: str,
status: str,
error: str | None = None,
notification_id: int | None = None,
message_id: str | None = None,
) -> int | None:
"""v0.18.0 Slice 4: write one row to `outbound_emails`.
Returns the inserted row's id, or `None` if the DB connection
isn't initialized (which happens in unit tests that don't boot
the full app the write is best-effort and never raises).
"""
try:
cur = db.conn().execute(
"""
INSERT INTO outbound_emails
(to_address, from_address, subject, kind, sent_at, status,
error, notification_id, message_id)
VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?)
""",
(
to_address,
from_address,
subject,
kind,
status,
error,
notification_id,
message_id,
),
)
return cur.lastrowid
except RuntimeError:
# db.conn() raises RuntimeError if init() hasn't been called.
# Pure-helper unit tests for build_envelope hit this path; the
# audit row is best-effort and not part of the contract.
return None
except Exception:
log.exception("outbound_emails write failed: to=%s subject=%s", to_address, subject)
return None
# ---------------------------------------------------------------------------
# Quiet-hours release pass — called from the digest job
# ---------------------------------------------------------------------------
@@ -440,13 +581,27 @@ def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int:
for r, _cat, extras in group_rows:
summary = _summary_for(r["event_kind"], r["actor_display"], r["rfc_title"], extras)
sections.append(f" · {summary}")
# v0.18.0: the bundle covers multiple categories, so a
# per-category opt-out can't honor the user's intent. The
# `all` category lands at the §15.4 endpoint and sets
# `email_opt_out_all = 1`.
unsubscribe_url = make_unsubscribe_url(user["id"], "all")
body = (
"Activity on RFCs you watch, accumulated during your quiet hours:\n"
+ "\n".join(sections)
+ f"\n\nOpen your inbox: {cfg.app_url}/inbox\n"
+ f"Manage all preferences: {cfg.app_url}/settings/notifications\n"
+ f"Unsubscribe from all email: {unsubscribe_url}\n"
)
sent = _deliver(
cfg,
user["email"],
subject,
body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="bundle",
)
sent = _deliver(cfg, user["email"], subject, body)
if not sent:
return 0
ids = [r["id"] for r, _, _ in emailable]
+155
View File
@@ -0,0 +1,155 @@
"""v0.18.0 / roadmap items #18 + #20: a shared envelope builder.
Every outbound mail in rfc-app today (OTC, admin-invite, watcher
notification, "while you were away" bundle, per-RFC invite) constructs
its own `email.message.EmailMessage` ad-hoc. The four sites diverged
just enough to be a deliverability hazard: missing `Date`, missing
`Message-ID`, no `Auto-Submitted`, no `List-Unsubscribe` on the
bulk-adjacent paths, no `multipart/alternative` body.
This module is the one place an `EmailMessage` is constructed. Every
send path imports `build_envelope` and calls it; the headers that
matter for inbox placement (Date, Message-ID, Auto-Submitted) land
uniformly, and the per-kind variations (unsubscribe semantics,
HTML alternative) are explicit arguments rather than buried in
each call site.
Per the v0.18.0 proposal at `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`,
the per-kind unsubscribe matrix is:
* OTC: no `List-Unsubscribe` (the recipient explicitly requested
the code; advertising an unsubscribe header would imply OHM has
them on a list, which it doesn't).
* Admin invite / per-RFC invite: `mailto:` form only (the
recipient isn't a user yet, so there's no per-user opt-out row
to flip; the operator handles ad-hoc opt-outs manually).
* Watcher notification / bundle: full `mailto:` + signed-URL
`List-Unsubscribe` plus `List-Unsubscribe-Post:
List-Unsubscribe=One-Click` per RFC 8058 (Gmail and Yahoo
enforce this for bulk-adjacent senders).
The `is_transactional` flag governs `Auto-Submitted: auto-generated`,
which prevents auto-responder loops on every kind of mail we send.
All five mail kinds today are transactional in the SMTP sense (no
human is at the From mailbox watching for replies), so the default
is True; the argument is exposed for future symmetry.
"""
from __future__ import annotations
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
def build_envelope(
*,
to_address: str,
from_address: str,
from_name: str,
subject: str,
body_plain: str,
body_html: str | None = None,
reply_to: str | None = None,
unsubscribe_mailto: str | None = None,
unsubscribe_url: str | None = None,
is_transactional: bool = True,
msgid_domain: str | None = None,
) -> EmailMessage:
"""Compose an `EmailMessage` with hardened headers.
`to_address` / `from_address` are bare RFC 5322 addresses;
`from_name` is the display label that goes through `formataddr`
so spaces / commas in the display string are encoded correctly.
`body_plain` is mandatory. `body_html` is **reserved and not yet
enabled** (security-audit-0026 I3): no send path supplies it today
every rfc-app mail is plain text and passing it raises
`NotImplementedError`. The parameter is kept in the signature for
documented future symmetry: when HTML mail is enabled it will land
as the second part of a `multipart/alternative` body (text/plain
first per RFC 2046, so a plain-text client picking the first part
still gets the readable text). Enabling it is a deliberate act the
caller MUST HTML-escape any user content into `body_html` first (cf.
the C1 stored-XSS class: a mail client renders the HTML) and remove
the guard below in the same change.
`reply_to`, when set, lets a send path point replies at a
different mailbox than the From line (e.g., a watcher
notification with From=notifications@... but Reply-To=
ohm@... so a confused recipient who hits Reply lands at a
monitored mailbox).
`unsubscribe_mailto` / `unsubscribe_url` populate
`List-Unsubscribe`. If `unsubscribe_url` is set, the helper also
emits `List-Unsubscribe-Post: List-Unsubscribe=One-Click` per
RFC 8058 Gmail and Yahoo POST that payload on the user's
one-click action. (Send paths that wire `unsubscribe_url`
therefore MUST also expose a matching POST endpoint that accepts
the same token; see `api_notifications.py:email_unsubscribe`.)
`msgid_domain` defaults to the @-domain of `from_address` so
Message-IDs are aligned with the sending domain by default. A
deployment that wants the Message-ID domain to track a different
surface (e.g., a tracking-domain that's separate from the From
domain) can override.
`Date` is RFC 5322 formatted via `email.utils.formatdate`; the
`localtime=True` setting picks the running process's local
timezone, which is what every popular MUA does too. (A
deployment running in UTC stamps UTC; that's correct, not a
bug.)
"""
msg = EmailMessage()
msg["From"] = formataddr((from_name, from_address))
msg["To"] = to_address
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
# If the caller didn't pin a Message-ID domain, derive it from the
# From address. `make_msgid` accepts None and falls back to the
# local hostname, which is the wrong shape for a deliverable
# message (the hostname might be `gke-pool-xxx`); a deployment
# without a configured From would surface that as a build-time
# config error elsewhere, so the fallback here is just defensive.
if msgid_domain is None:
if "@" in from_address:
msgid_domain = from_address.split("@", 1)[1]
else:
msgid_domain = "localhost"
msg["Message-ID"] = make_msgid(domain=msgid_domain)
if reply_to:
msg["Reply-To"] = reply_to
if is_transactional:
# RFC 3834: prevents auto-responders (vacation replies, etc.)
# from triggering on this message. Every kind of mail rfc-app
# sends today is transactional in this sense.
msg["Auto-Submitted"] = "auto-generated"
if unsubscribe_mailto or unsubscribe_url:
parts: list[str] = []
if unsubscribe_mailto:
parts.append(f"<mailto:{unsubscribe_mailto}>")
if unsubscribe_url:
parts.append(f"<{unsubscribe_url}>")
msg["List-Unsubscribe"] = ", ".join(parts)
if unsubscribe_url:
# RFC 8058 one-click. Gmail and Yahoo POST the payload
# `List-Unsubscribe=One-Click` to the URL on the user's
# one-click action; the matching POST endpoint must be
# idempotent and not require auth. See
# `api_notifications.py` for the receiver.
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
if body_html is not None:
# I3 (security-audit-0026): the multipart/alternative HTML path
# is intentionally NOT enabled. No send path passes `body_html`
# today, and emitting an HTML body built from user-supplied
# content without escaping it first would reintroduce the C1
# stored-XSS class in the mail channel (the recipient's client
# renders the HTML). Fail loudly here rather than silently
# shipping HTML: enabling HTML mail is a deliberate change that
# MUST HTML-escape user content at the call site and remove this
# guard together. The text/plain path below is the only live one.
raise NotImplementedError(
"HTML email is not enabled (security-audit-0026 I3): do not "
"pass body_html until user content is HTML-escaped at the "
"call site and this guard is intentionally removed."
)
msg.set_content(body_plain)
return msg
+38 -8
View File
@@ -31,10 +31,10 @@ from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT
from .email import EmailConfig, _SENT, record_outbound
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -59,31 +59,52 @@ def send_invite_email(
cfg = EmailConfig.from_env()
subject = _subject(inviter_display, cfg)
body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
# v0.18.0: invite mail carries a `List-Unsubscribe: <mailto:…>`
# only (no signed URL) — the invitee isn't a user yet, so there
# is no per-user opt-out row to flip. The operator handles
# ad-hoc opt-outs from the mailto: target. Per the proposal's
# "Tradeoff discussion": the invite was unsolicited from the
# recipient's perspective, so the courtesy header is right;
# but it can't be a one-click URL because the row doesn't
# exist yet.
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "invite",
"message": msg,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.enabled:
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="deferred", message_id=message_id,
)
return True
if not cfg.smtp_host:
# Dev fallback: surface the claim URL at INFO so the operator can
# complete a claim flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="deferred", message_id=message_id,
)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
@@ -93,9 +114,18 @@ def send_invite_email(
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="sent", message_id=message_id,
)
return True
except Exception:
except Exception as exc:
log.exception("invite email send failed: to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="failed",
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
)
return False
+34 -8
View File
@@ -23,10 +23,10 @@ from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT
from .email import EmailConfig, _SENT, record_outbound
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -44,31 +44,48 @@ def send_otc_email(to_address: str, code: str) -> bool:
cfg = EmailConfig.from_env()
subject = f"Your sign-in code for {cfg.from_name}"
body = _body(code, cfg)
# v0.18.0: OTC mail carries NO List-Unsubscribe — the recipient
# explicitly requested the code; advertising an unsubscribe
# header would imply OHM has them on a list, which it doesn't.
# See the proposal's "Tradeoff discussion" for the binding
# rationale.
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "otc",
"message": msg,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.enabled:
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="deferred", message_id=message_id,
)
return True
if not cfg.smtp_host:
# Dev fallback: surface the code at INFO so the operator can
# complete a sign-in flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="deferred", message_id=message_id,
)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
@@ -78,9 +95,18 @@ def send_otc_email(to_address: str, code: str) -> bool:
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="sent", message_id=message_id,
)
return True
except Exception:
except Exception as exc:
log.exception("otc email send failed: to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="failed",
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
)
return False
+64 -3
View File
@@ -30,7 +30,7 @@ _ABSENT = object()
class Entry:
slug: str
title: str
state: str = "super-draft" # super-draft | active | withdrawn
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
id: str | None = None # 'RFC-NNNN' or None
repo: str | None = None
proposed_by: str = ""
@@ -50,7 +50,28 @@ class Entry:
# operator credentials per §18 are used. The binding is inert until
# the named user has a funder_consents row (the hybrid two-key rule).
funder: str | None = None
# §22.4c: an `active` entry that landed without a human review gate
# carries unreviewed=True until an owner clears it. Orthogonal to
# `state`; only meaningful for active entries. reviewed_at/reviewed_by
# are the provenance of the clear, paralleling graduated_at/by.
unreviewed: bool = False
reviewed_at: str | None = None
reviewed_by: str | None = None
body: str = ""
# §22.4a (configurable collection metadata, SLICE-1): frontmatter / sidecar
# keys outside the known set above are preserved here verbatim so they ride
# along untouched through a parse→serialize round-trip and the
# frontmatter→sidecar migration (INV-7). Includes future collection-`fields:`
# schema values, which the engine does not interpret.
extra: dict[str, Any] = field(default_factory=dict)
# Frontmatter keys the Entry models explicitly; everything else is `extra`.
KNOWN_KEYS = {
"slug", "title", "state", "id", "repo", "proposed_by", "proposed_at",
"graduated_at", "graduated_by", "owners", "arbiters", "tags", "models",
"funder", "unreviewed", "reviewed_at", "reviewed_by",
}
def parse(text: str) -> Entry:
@@ -59,6 +80,16 @@ def parse(text: str) -> Entry:
raise ValueError("Entry file missing frontmatter")
fm = yaml.safe_load(match.group(1)) or {}
body = match.group(2).lstrip("\n")
return from_frontmatter(fm, body)
def from_frontmatter(fm: dict[str, Any], body: str = "") -> Entry:
"""Build an Entry from an already-parsed metadata mapping + body.
Shared by `parse()` (legacy `.md` frontmatter) and the SLICE-1 dual-read
sidecar path (`metadata.read_entry`), so both produce identical records
(INV-6). `fm` keys outside `KNOWN_KEYS` are preserved on `Entry.extra`.
"""
raw_models = fm.get("models", _ABSENT)
if raw_models is _ABSENT or raw_models is None:
models: list[str] | None = None
@@ -66,6 +97,8 @@ def parse(text: str) -> Entry:
models = [str(m) for m in raw_models]
raw_funder = fm.get("funder")
funder = str(raw_funder).strip() if raw_funder else None
unreviewed = bool(fm.get("unreviewed") or False)
extra = {k: v for k, v in fm.items() if k not in KNOWN_KEYS}
return Entry(
slug=str(fm.get("slug") or ""),
title=str(fm.get("title") or ""),
@@ -81,12 +114,22 @@ def parse(text: str) -> Entry:
tags=list(fm.get("tags") or []),
models=models,
funder=funder,
unreviewed=unreviewed,
reviewed_at=fm.get("reviewed_at") or None,
reviewed_by=fm.get("reviewed_by") or None,
body=body,
extra=extra,
)
def serialize(entry: Entry) -> str:
"""Emit canonical entry file text — frontmatter then body."""
def to_frontmatter_dict(entry: Entry) -> dict[str, Any]:
"""The canonical ordered metadata mapping for an entry.
Shared by `serialize()` (which wraps it in `---` fences over the body) and
the SLICE-1 sidecar writer (`metadata.sidecar_yaml`, which emits the same
mapping as a standalone `<slug>.meta.yaml`). Known keys first in canonical
order, then `extra` (INV-7).
"""
fm: dict[str, Any] = {
"slug": entry.slug,
"title": entry.title,
@@ -110,6 +153,24 @@ def serialize(entry: Entry) -> str:
# second meaning here as with `models:`; one set of semantics.
if entry.funder:
fm["funder"] = entry.funder
# §22.4c: emit unreviewed only when True (a super-draft / reviewed
# active entry leaves the key absent → frontmatter stays minimal).
if entry.unreviewed:
fm["unreviewed"] = True
if entry.reviewed_at:
fm["reviewed_at"] = entry.reviewed_at
if entry.reviewed_by:
fm["reviewed_by"] = entry.reviewed_by
# INV-7: forward-compat / unknown keys ride along after the known ones.
for k, v in entry.extra.items():
if k not in fm:
fm[k] = v
return fm
def serialize(entry: Entry) -> str:
"""Emit canonical entry file text — frontmatter then body."""
fm = to_frontmatter_dict(entry)
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
body = entry.body.lstrip("\n")
if body:
+110
View File
@@ -0,0 +1,110 @@
"""§22.4a SLICE-3 — faceted catalog filtering + counts (read).
Pure functions over already-mirrored entries; no I/O, no DB. An "entry" here is
a plain dict carrying at least:
- "state": the lifecycle state column,
- "metadata_malformed": bool,
- "meta": the per-entry metadata mapping (from cached_rfcs.meta_json).
Facetable fields (§5.1, plan decision 1): a collection's declared `enum` and
`tags` fields, in declaration order, plus the built-in `state` facet appended
last but only when the collection declares a schema (INV-5: a no-`fields:`
collection has no facets at all, so the frontend keeps its legacy chips). `text`
fields are not faceted in v1 (they get a detail control in SLICE-4).
Counts use drill-down semantics (plan decision 2): the count for a value of
field F is taken over entries matching every OTHER field's selection (and the
malformed toggle), not F's own — so within-field values stay switchable (OR
within a field, AND across fields). The returned items list applies ALL
selections.
"""
from __future__ import annotations
from typing import Any
# enum + tags are facetable; text is rendered as a detail control (SLICE-4).
FACETABLE_TYPES = {"enum", "tags"}
def facet_fields(fields: dict[str, dict] | None) -> list[tuple[str, str]]:
"""Ordered `[(name, type), ...]` facetable from the schema, `state` last.
Empty when the collection declares no schema (INV-5)."""
if not fields:
return []
out = [
(name, spec.get("type"))
for name, spec in fields.items()
if spec.get("type") in FACETABLE_TYPES
]
out.append(("state", "enum"))
return out
def allowed_filter_keys(fields: dict[str, dict] | None) -> set[str]:
"""Query-param keys the collection-scoped list accepts (plan decision 6)."""
keys = {name for name, _ in facet_fields(fields)}
keys.update({"unreviewed", "malformed"})
return keys
def _values_for(entry: dict[str, Any], name: str, ftype: str) -> list[str]:
"""The facet value(s) an entry contributes for field `name` (str-cast)."""
if name == "state":
v = entry.get("state")
return [str(v)] if v else []
meta = entry.get("meta") or {}
v = meta.get(name)
if v is None:
return []
if ftype == "tags":
return [str(x) for x in v] if isinstance(v, list) else []
return [str(v)]
def _matches(entry: dict[str, Any], name: str, ftype: str, selected: set[str]) -> bool:
if not selected:
return True
return bool(set(_values_for(entry, name, ftype)) & selected) # OR within field
def filter_and_count(
entries: list[dict[str, Any]],
fields: dict[str, dict] | None,
selections: dict[str, set[str]],
only_malformed: bool = False,
) -> tuple[list[dict[str, Any]], dict[str, dict[str, int]]]:
"""Filter `entries` by `selections` and compute drill-down facet counts.
`selections` maps a facet field name the set of selected values (OR within
the field; AND across fields). `only_malformed` narrows items and counts to
entries flagged malformed (INV-3). Returns `(items, facets)` where
`facets = {field: {value: count}}`. With no schema `([all passing], {})`.
"""
facetable = facet_fields(fields)
def passes_malformed(e: dict[str, Any]) -> bool:
return (not only_malformed) or bool(e.get("metadata_malformed"))
items = [
e for e in entries
if passes_malformed(e)
and all(_matches(e, n, t, selections.get(n, set())) for n, t in facetable)
]
facets: dict[str, dict[str, int]] = {}
for name, ftype in facetable:
counts: dict[str, int] = {}
for e in entries:
if not passes_malformed(e):
continue
if not all(
_matches(e, on, ot, selections.get(on, set()))
for on, ot in facetable
if on != name
):
continue
for val in _values_for(e, name, ftype):
counts[val] = counts.get(val, 0) + 1
facets[name] = counts
return items, facets
+1 -1
View File
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
db.conn().execute(
"""
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
ON CONFLICT(user_id, rfc_slug) DO NOTHING
ON CONFLICT(collection_id, user_id, rfc_slug) DO NOTHING
""",
(user_id, slug),
)
+34
View File
@@ -193,6 +193,40 @@ class Gitea:
resp = await self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=body)
return resp.json()
async def change_files(
self,
owner: str,
repo: str,
*,
files: list[dict[str, Any]],
message: str,
branch: str,
author_name: str | None = None,
author_email: str | None = None,
) -> dict:
"""Create/update/delete several files in ONE commit (Gitea ChangeFiles).
Each `files` entry is `{"operation": "create"|"update"|"delete",
"path": str, "content": str (for create/update), "sha": str (required
for update/delete)}`. Plaintext `content` is base64-encoded here.
Backs the §22.4a frontmattersidecar migration's "one commit per
collection" (`metadata_migrate`).
"""
out_files: list[dict[str, Any]] = []
for f in files:
item: dict[str, Any] = {"operation": f["operation"], "path": f["path"]}
if "content" in f and f["content"] is not None:
item["content"] = base64.b64encode(f["content"].encode("utf-8")).decode("ascii")
if f.get("sha"):
item["sha"] = f["sha"]
out_files.append(item)
body: dict[str, Any] = {"message": message, "branch": branch, "files": out_files}
if author_name and author_email:
body["author"] = {"name": author_name, "email": author_email}
body["committer"] = {"name": author_name, "email": author_email}
resp = await self._request("POST", f"/repos/{owner}/{repo}/contents", json=body)
return resp.json()
# ----- Pull requests -----
async def list_pulls(self, owner: str, repo: str, state: str = "open") -> list[dict]:
+17 -8
View File
@@ -30,7 +30,7 @@ import logging
import os
from datetime import datetime, timedelta, timezone
from . import db
from . import db, projects as projects_mod
from .bot import Bot
from .config import Config
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
reason: str,
) -> bool:
"""Call `bot.delete_branch` with the system actor. Resolves the
`(org, repo)` pair from the slug: super-draft edit branches and
graduation branches live on the meta repo; active-RFC branches
live on the per-RFC repo named by `cached_rfcs.repo`.
`(org, repo)` pair from the slug: under the meta-only topology (§1)
every meta-resident entry's edit branches and graduation branches
live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
Returns True on a clean delete; False if the rfc row is missing
(we leave the branch row in place a subsequent reconciler sweep
@@ -297,12 +298,20 @@ async def _delete_branch_via_bot(
if rfc is None:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False
if rfc["state"] == "super-draft":
owner, repo = config.gitea_org, config.meta_repo
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
if not rfc["repo"]:
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning(
"hygiene: default project has no content_repo; skipping branch delete for %s/%s",
slug, branch,
)
return False
owner = config.gitea_org
elif "/" in rfc["repo"]:
owner, repo = rfc["repo"].split("/", 1)
else:
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"])
log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
slug, rfc["state"], rfc["repo"])
return False
try:
await bot.delete_branch(
+10 -4
View File
@@ -144,10 +144,16 @@ def create_invite(
The invitee `users` row is provisioned with:
* `permission_state='granted'` the admin's hand is the grant;
the v0.8.0 self-serve `pending` queue is for the other path.
* `last_seen_at = NULL` the discriminator for "invited but
not yet arrived" per the §16 / roadmap design. Every sign-in
path stamps `last_seen_at` to now, so a NULL value means the
invited user has not clicked through yet.
* `created_at` / `last_seen_at` NOT set here, so both fall
through to the column default `datetime('now')` (the column is
`NOT NULL`; see `migrations/001_users_and_audit.sql` and the
longer note below). The "invited but not yet arrived" state is
therefore NOT carried on the user row it is the existence of
an unclaimed `user_invite_tokens` row, surfaced as the listing's
`pending_invite` field. Consumers that want a truthful
last-seen MUST treat a pending-invite row as never-seen rather
than trusting `last_seen_at` (every real sign-in path stamps it
to now, but an unclaimed invite has never hit one).
* `gitea_id = NULL`, `gitea_login = NULL` same as a v0.7.0
OTC-provisioned user; the OAuth identity is grandfathered if
the user ever lands through that path.
+166 -20
View File
@@ -7,6 +7,7 @@ no need for a separate worker.
from __future__ import annotations
import logging
import os
import secrets
from contextlib import asynccontextmanager
@@ -27,7 +28,10 @@ from . import (
invites as invites_mod,
otc,
passcode as passcode_mod,
projects,
providers as providers_mod,
ratelimit,
registry as registry_mod,
turnstile,
webhooks,
)
@@ -62,6 +66,13 @@ class OtcVerifyBody(BaseModel):
trust_device: bool = False
class TestLoginBody(BaseModel):
# The single configured test identity to sign in as. Must equal
# E2E_TEST_AUTH_EMAIL (case-insensitive) or the request is refused —
# see `/auth/test/login`.
email: str = Field(min_length=3, max_length=320)
class PasscodeSetBody(BaseModel):
passcode: str = Field(min_length=1, max_length=64)
@@ -97,7 +108,47 @@ async def lifespan(app: FastAPI):
config = load_config()
db.run_migrations(config)
db.init(config)
# v0.52.0: shout if the deployed-env E2E test-auth shortcut is live.
# It mints owner sessions for one configured identity (see
# `/auth/test/login`); it must only ever be on for a pre-prod (PPE)
# host. A loud startup line means an accidental prod enablement is
# visible in the logs rather than silent.
if os.environ.get("E2E_TEST_AUTH_SECRET", "").strip() and os.environ.get(
"E2E_TEST_AUTH_EMAIL", ""
).strip():
log.warning(
"E2E TEST-AUTH IS ENABLED: POST /auth/test/login will mint an owner "
"session for %s. This must NEVER be set on production.",
os.environ["E2E_TEST_AUTH_EMAIL"].strip(),
)
gitea = Gitea(config)
# §22 framework heal: reconcile a divergent default-project collection id
# (migration 029's ≥2-projects seed names it after the project, e.g. 'ohm',
# but the mirror expects 'default') BEFORE the mirror runs, so the mirror
# merges onto the canonical 'default' collection instead of duplicating it.
# Idempotent no-op on fresh / single-project / already-aligned deployments.
projects.reconcile_default_collection_id(config)
# §22.2: mirror the registry before anything reads projects/content_repo.
# First boot has no last-good rows, so a missing/invalid registry is fatal
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
# fresh thereafter and tolerates a later bad PR.
try:
await registry_mod.refresh_registry(config, gitea)
except Exception as e:
raise RuntimeError(
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
) from e
# §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
# deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
# exists, so the original corpus lands at a meaningful /p/<id>/ and
# 'default' is never a public URL. Idempotent no-op once done.
projects.restamp_default_project(config)
if projects.default_content_repo(config) is None:
raise RuntimeError(
f"registry does not describe the default project "
f"{projects.resolved_default_id(config)!r} (no content_repo). "
f"Add it to {config.registry_repo_full}/projects.yaml."
)
bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea)
digest_sched = digest.DigestScheduler()
@@ -126,7 +177,7 @@ async def lifespan(app: FastAPI):
reconciler.start()
digest_sched.start()
hygiene_sched.start()
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
log.info("RFC app started — registry %s", config.registry_repo_full)
try:
yield
finally:
@@ -142,12 +193,20 @@ def create_app() -> FastAPI:
# eagerly via load_config(). Everything else waits for lifespan.
config = load_config()
app = FastAPI(lifespan=lifespan)
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
# auth credential and must carry `Secure` in production so it never
# travels cleartext. Default to Secure; a dev box serving over plain
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
"0", "false", "no", "off",
)
app.add_middleware(
SessionMiddleware,
secret_key=config.secret_key,
session_cookie="rfc_session",
max_age=60 * 60 * 24 * 30,
https_only=False,
https_only=session_secure,
)
return app
@@ -155,24 +214,25 @@ def create_app() -> FastAPI:
app = create_app()
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
"""Attach the v0.11.0 device-trust cookie to the response.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
cookie value is the raw token; server-side storage is the hash.
The cookie is "essential" per the v0.13.0 cookie-consent contract
(it is part of authentication), so we set it regardless of the
user's analytics / other-cookies choice.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value`
"<row_id>.<raw_token>" so `device_trust.lookup` can read one indexed
row instead of scanning; server-side storage remains the bcrypt hash
of the token half only. The cookie is "essential" per the v0.13.0
cookie-consent contract (it is part of authentication), so we set it
regardless of the user's analytics / other-cookies choice.
Secure=True means the cookie is only ever sent over HTTPS. The
SessionMiddleware in `create_app` keeps `https_only=False` for
dev parity, but the device-trust cookie holds a 30-day credential
and must not travel cleartext production deployments serve over
HTTPS, so Secure on the device-trust cookie is non-negotiable.
Secure=True means the cookie is only ever sent over HTTPS the
device-trust cookie holds a 30-day credential and must never travel
cleartext. (The session cookie now also defaults to Secure; see M4 in
`create_app`.)
"""
response.set_cookie(
key=device_trust_mod.COOKIE_NAME,
value=raw_token,
value=cookie_value,
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
path="/",
secure=True,
@@ -245,6 +305,10 @@ def _oauth_router(config) -> APIRouter:
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody, request: Request):
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
# before the Turnstile network call or any bcrypt/SMTP work.
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
raise HTTPException(429, "Too many requests; please wait a few minutes")
# v0.12.0 / roadmap item #10: gate the request on a successful
# Turnstile siteverify before the bcrypt hash + SMTP send. The
# check runs first so a failed challenge spends no rate budget
@@ -252,7 +316,7 @@ def _oauth_router(config) -> APIRouter:
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
ts = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok:
if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This
@@ -278,9 +342,25 @@ def _oauth_router(config) -> APIRouter:
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
# plus the per-email lockout enforced inside otc.verify_code.
ip = ratelimit.client_key(request)
if not ratelimit.verify_limiter.allow(ip):
raise HTTPException(429, "Too many attempts; please wait a few minutes")
result = otc.verify_code(body.email, body.code)
if result.reason == "locked":
raise HTTPException(
423,
{
"detail": "Too many failed attempts; wait a few minutes or request a new code",
"locked_until": result.locked_until,
},
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
# Legit sign-in: clear this IP's window so a user who fat-fingered
# a couple of codes isn't left throttled.
ratelimit.verify_limiter.reset(ip)
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
@@ -313,7 +393,7 @@ def _oauth_router(config) -> APIRouter:
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
return {
"ok": True,
"user": {
@@ -326,6 +406,61 @@ def _oauth_router(config) -> APIRouter:
"needs_profile": needs_profile,
}
# ---------------------------------------------------------------
# v0.52.0: deployed-environment E2E test-auth shortcut.
#
# Running the Playwright E2E suite against a *deployed* environment
# (PPE) is the §9 pre-prod gate. But the deployed env has neither of
# the two scaffolds the Tier-1 docker stack relies on for auth: a
# Mailpit sink to read the OTC code from, and direct SQLite access to
# inject a granted-owner row. This endpoint replaces both with a
# single gated gesture: it mints an authenticated OWNER session for
# one pre-configured throwaway identity.
#
# It is FAIL-CLOSED and must never function in production:
# * 404 unless BOTH `E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`
# are set — a prod deployment that sets neither cannot be coaxed
# into minting a session, and the route is invisible.
# * The caller must present the shared secret in `X-Test-Auth-Secret`
# (constant-time compare); a wrong/absent secret 404s (the route
# does not advertise itself to an unauthenticated caller).
# * Only the one configured email may be minted; any other address
# is refused (403). So an enabled PPE exposes exactly one
# throwaway owner identity, with the secret as the trust boundary.
#
# The hard secrets rule (§6.3) holds: the secret is a Secret Manager
# ref injected as env on the VM (never a literal in the repo), and the
# E2E runner presents it from SM at runtime (never echoed).
@router.post("/auth/test/login")
async def test_login(body: TestLoginBody, request: Request):
secret = os.environ.get("E2E_TEST_AUTH_SECRET", "").strip()
configured_email = os.environ.get("E2E_TEST_AUTH_EMAIL", "").strip()
# Feature off (the default, incl. production): route is invisible.
if not secret or not configured_email:
raise HTTPException(404, "Not Found")
presented = request.headers.get("x-test-auth-secret", "")
if not secrets.compare_digest(presented, secret):
# Don't reveal that the route exists to a caller without the
# secret — mirror the "off" shape exactly.
raise HTTPException(404, "Not Found")
if body.email.strip().lower() != configured_email.lower():
raise HTTPException(403, "email not permitted")
# Provision-or-link the row, then force it to a granted owner so
# the metadata write paths (SLICE-4/5) accept it — the deployed
# equivalent of the Tier-1 docker-compose backend-seed owner row.
user = otc.provision_or_link_user(body.email)
db.conn().execute(
"UPDATE users SET role = 'owner', permission_state = 'granted', "
"last_seen_at = datetime('now') WHERE id = ?",
(user.user_id,),
)
db.conn().commit()
user.role = "owner"
user.permission_state = "granted"
auth.store_session(request, user)
return {"ok": True}
# ---------------------------------------------------------------
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
#
@@ -337,12 +472,17 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.get("/auth/passcode/check")
async def passcode_check(email: str = ""):
async def passcode_check(request: Request, email: str = ""):
"""Does this email have a passcode set? Anonymous endpoint —
the Login.jsx flow calls this after the user types their email
to decide whether to render a passcode input or fall back to
OTC. We surface only the boolean; lockout state, the hash, and
the set-at stamp are not leaked here."""
the set-at stamp are not leaked here.
v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
boolean can't be bulk-harvested to enumerate accounts."""
if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
raise HTTPException(429, "Too many requests; please wait a few minutes")
status = passcode_mod.passcode_status(email)
return {"has_passcode": status.has_passcode}
@@ -376,6 +516,11 @@ def _oauth_router(config) -> APIRouter:
v0.11.0: the body's `trust_device` flag, if true, mints a
fresh device-trust row and sets the long-lived cookie. Same
opt-in contract as `/auth/otc/verify`."""
# v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
# passcode lockout, so fan-out across emails is throttled too.
ip = ratelimit.client_key(request)
if not ratelimit.verify_limiter.allow(ip):
raise HTTPException(429, "Too many attempts; please wait a few minutes")
result = passcode_mod.verify_passcode(body.email, body.passcode)
if result.reason == "locked":
raise HTTPException(
@@ -387,11 +532,12 @@ def _oauth_router(config) -> APIRouter:
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid passcode")
ratelimit.verify_limiter.reset(ip)
auth.store_session(request, result.user)
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
return {
"ok": True,
"user": {
@@ -459,7 +605,7 @@ def _oauth_router(config) -> APIRouter:
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
# Has the user already set a passcode? (Could only happen via
# an admin pre-population path that doesn't exist yet, but
+141
View File
@@ -0,0 +1,141 @@
"""§22 S4 (C.2) — scope-role grant operations over the `memberships` table.
The membership *gates* (who may invite, who holds which role) live in
`auth.py`; this module holds the *mutations* the invitation surface drives
granting, the "broader scope supersedes narrower" cleanup, listing, and
revocation mirroring how `invites.py` owns the create/claim/list of per-user
invite tokens while the gate (`auth.can_invite_to_rfc`) lives in `auth.py`.
The model (Part B / S3): a `memberships` row is `(scope_type {global,
project, collection}, scope_id, user_id, role {owner, contributor})`, unique
per `(scope_type, scope_id, user_id)`. A grant is a direct write of that row
(the C.2 scenarios write the row immediately and §15-notify an existing
account there is no accept round-trip; inviting a not-yet-account email is
out of S4 scope and handled by the admin-create-invite path).
The "broader scope supersedes narrower" rule (C.2.6): granting a role at a
broader scope removes this user's narrower rows that the new grant *subsumes*
a narrower row whose role is no more permissive than the new one. A narrower
row that is *more* permissive is kept (no negative override: a child Owner
grant survives a parent Contributor grant, and the §B.2 resolver still unions
most-permissively).
"""
from __future__ import annotations
from typing import Any
from . import collections as collections_mod
from . import db
# Higher rank = more permissive. Used by the supersede rule: a narrower grant
# is pruned only when its rank ≤ the new broader grant's rank.
_ROLE_RANK = {"contributor": 1, "owner": 2}
VALID_SCOPE_TYPES = ("global", "project", "collection")
VALID_ROLES = ("owner", "contributor")
GLOBAL_SCOPE_ID = "*"
def user_by_email(email: str) -> dict[str, Any] | None:
"""The `users` row (id, display_name, email, permission_state) for an
email, case-insensitively, or None. The grantee must already be an account
S4 grants a scope role to an existing user, it does not provision one."""
row = db.conn().execute(
"SELECT id, display_name, email, permission_state, role "
"FROM users WHERE lower(email) = lower(?) "
"ORDER BY id LIMIT 1",
(email.strip(),),
).fetchone()
return dict(row) if row else None
def grant(
*,
scope_type: str,
scope_id: str,
user_id: int,
role: str,
granted_by: int | None,
) -> None:
"""Write (or update) the membership row, then apply the C.2.6
broader-scope-supersedes cleanup. Idempotent on `(scope_type, scope_id,
user_id)` re-granting at the same scope updates the role and the grantor.
The grant is recorded regardless of the grantee's deployment
`permission_state`: a `pending` account's row is written (C.2.7), but the
§6 admission floor in `auth.effective_scope_role` keeps it conferring no
write until the account is granted at the deployment."""
db.conn().execute(
"INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by) "
"VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT (scope_type, scope_id, user_id) "
"DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, "
"granted_at = datetime('now')",
(scope_type, scope_id, user_id, role, granted_by),
)
_prune_subsumed(scope_type=scope_type, scope_id=scope_id, user_id=user_id, role=role)
def _prune_subsumed(*, scope_type: str, scope_id: str, user_id: int, role: str) -> None:
"""Remove this user's narrower rows that the just-written broader grant
subsumes (same-or-lower role rank within the broader scope's subtree). A
collection grant subsumes nothing narrower (the per-entry tier is separate);
a project grant subsumes its collections; a global grant subsumes every
project and collection."""
rank = _ROLE_RANK[role]
keep_ranks = [r for r, v in _ROLE_RANK.items() if v <= rank]
if not keep_ranks:
return
placeholders = ",".join("?" for _ in keep_ranks)
if scope_type == "project":
# Narrower = collection-scope rows for collections in this project.
db.conn().execute(
f"DELETE FROM memberships "
f"WHERE user_id = ? AND scope_type = 'collection' "
f" AND role IN ({placeholders}) "
f" AND scope_id IN (SELECT id FROM collections WHERE project_id = ?)",
(user_id, *keep_ranks, scope_id),
)
elif scope_type == "global":
# Narrower = every project- and collection-scope row for this user.
db.conn().execute(
f"DELETE FROM memberships "
f"WHERE user_id = ? AND scope_type IN ('project', 'collection') "
f" AND role IN ({placeholders})",
(user_id, *keep_ranks),
)
def revoke(*, scope_type: str, scope_id: str, user_id: int) -> bool:
"""Remove a membership row at exactly this scope. Returns True if a row was
removed. Revocation is scope-exact: it does not cascade to broader or
narrower grants (each is its own administrative act)."""
cur = db.conn().execute(
"DELETE FROM memberships WHERE scope_type = ? AND scope_id = ? AND user_id = ?",
(scope_type, scope_id, user_id),
)
return cur.rowcount > 0
def list_for_project(project_id: str) -> list[dict[str, Any]]:
"""Every project-scope grant on this project plus every collection-scope
grant on its collections, joined to the grantee's display fields — the data
behind the project-Owner membership panel. Ordered project grants first,
then by collection, then by role (Owner before Contributor)."""
rows = db.conn().execute(
"""
SELECT m.scope_type, m.scope_id, m.user_id, m.role, m.granted_at,
u.display_name, u.email, u.permission_state,
c.name AS collection_name
FROM memberships m
JOIN users u ON u.id = m.user_id
LEFT JOIN collections c ON c.id = m.scope_id AND m.scope_type = 'collection'
WHERE (m.scope_type = 'project' AND m.scope_id = ?)
OR (m.scope_type = 'collection'
AND m.scope_id IN (SELECT id FROM collections WHERE project_id = ?))
ORDER BY (m.scope_type != 'project'), m.scope_id,
CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, u.display_name
""",
(project_id, project_id),
).fetchall()
return [dict(r) for r in rows]
+311
View File
@@ -0,0 +1,311 @@
"""§22.4a configurable collection metadata — sidecar storage + dual-read.
SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md.
Entry metadata is collection-configured and stored in a per-entry sidecar,
`<slug>.meta.yaml`, with the `.md` body kept as pure prose (INV-2). This module
is the storage/compat layer:
- the **dual-read** parser (`read_entry`) read the sidecar if present, else
legacy top-of-document frontmatter, with identical resulting records
(INV-6);
- sidecar (de)serialization that preserves unknown / forward-compat keys
(INV-7), reusing `entry`'s canonical field semantics;
- lenient parsing that never hard-fails a read a malformed sidecar surfaces
a flag, not an exception (INV-3).
The collection `fields:` schema and per-field validation are SLICE-2; faceted
filtering and the edit UIs are later slices. This module interprets no field
values it only moves metadata between git and in-memory `Entry` records.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import yaml
from . import entry as entry_mod
from .entry import Entry
SIDECAR_SUFFIX = ".meta.yaml"
# ----- filename helpers -----
def sidecar_name(slug: str) -> str:
"""The sidecar filename for an entry whose markdown is `<slug>.md`."""
return f"{slug}{SIDECAR_SUFFIX}"
def is_sidecar(name: str) -> bool:
return name.endswith(SIDECAR_SUFFIX)
def slug_of_sidecar(name: str) -> str:
"""The entry stem for a `<slug>.meta.yaml` filename."""
return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name
def sidecar_path_for(md_path: str) -> str:
"""The sidecar path sibling to a `<dir>/<slug>.md` entry file."""
assert md_path.endswith(".md"), md_path
return md_path[: -len(".md")] + SIDECAR_SUFFIX
# ----- metadata <-> sidecar -----
def metadata_dict(entry: Entry) -> dict[str, Any]:
"""The full metadata mapping for an entry (known fields + `extra`)."""
return entry_mod.to_frontmatter_dict(entry)
def sidecar_yaml(entry: Entry) -> str:
"""Render an entry's metadata as standalone `<slug>.meta.yaml` text."""
return yaml.safe_dump(
metadata_dict(entry), sort_keys=False, default_flow_style=False
)
def parse_sidecar(text: str) -> tuple[dict[str, Any], bool]:
"""Parse sidecar YAML leniently → `(values, malformed)`.
`malformed` is True when the text is not a YAML mapping (a list, a scalar,
or a YAML syntax error). An empty / whitespace-only sidecar is an empty
mapping, not malformed. Never raises (INV-3).
"""
try:
raw = yaml.safe_load(text)
except yaml.YAMLError:
return {}, True
if raw is None:
return {}, False
if not isinstance(raw, dict):
return {}, True
return raw, False
# ----- frontmatter stripping (INV-2) -----
def strip_frontmatter(md_text: str) -> str:
"""Return the prose body of a `.md`, dropping a leading `---…---` block.
A migrated entry's `.md` is body-only and passes through unchanged. A
not-yet-migrated `.md` still carrying frontmatter yields just its body, so
the dual-read body is the same either way.
"""
match = entry_mod.FRONTMATTER_RE.match(md_text)
if not match:
return md_text
return match.group(2).lstrip("\n")
# ----- dual-read (INV-6) -----
def read_entry(
md_text: str, sidecar_text: str | None, *, fallback_slug: str | None = None
) -> tuple[Entry, bool]:
"""Read an entry from its `.md` and optional sidecar → `(Entry, malformed)`.
- **Sidecar present and well-formed (non-empty):** metadata comes from the
sidecar; the body is the `.md` stripped of any leading frontmatter. The
sidecar is the source of truth (INV-1) and wins over stale `.md`
frontmatter.
- **Sidecar present but malformed:** the entry still loads from the legacy
`.md` frontmatter (if any) and is flagged `malformed` (INV-3).
- **Sidecar present but empty:** it has nothing to override with, so fall
back to the `.md` frontmatter (not flagged).
- **No sidecar:** the legacy path parse the `.md` frontmatter (INV-6).
`fallback_slug` (typically the filename stem) backstops the entry's slug
whenever the metadata source lacks one so a degenerate sidecar never
yields a slug-less record the caller has to silently drop (INV-3).
"""
def _with_slug(entry: Entry) -> Entry:
if not entry.slug and fallback_slug:
entry.slug = fallback_slug
return entry
if sidecar_text is None:
return _with_slug(entry_mod.parse(md_text)), False
values, malformed = parse_sidecar(sidecar_text)
if malformed or not values:
# Malformed or empty sidecar: load from the legacy .md so the entry
# still loads; flag only when the sidecar was actually malformed.
try:
entry = entry_mod.parse(md_text)
except ValueError:
entry = entry_mod.from_frontmatter({}, strip_frontmatter(md_text))
return _with_slug(entry), malformed
body = strip_frontmatter(md_text)
return _with_slug(entry_mod.from_frontmatter(values, body)), False
# ----- value editing (SLICE-4) -----
def apply_values(entry: Entry, values: dict[str, Any]) -> Entry:
"""Return a new Entry with `values` merged over the entry's metadata.
Known keys (`tags`, `state`, `reviewed_by`, ) land on their typed fields;
unknown keys land on `extra` (INV-7). The body is carried through unchanged
this mutates metadata only. Unspecified keys are preserved.
"""
merged = metadata_dict(entry)
merged.update(values)
return entry_mod.from_frontmatter(merged, entry.body)
# ----- git-aware read/write (SLICE-4) -----
@dataclass
class EntryGitState:
"""An entry's on-disk state across its `.md` and optional sidecar.
Captured by `read_entry_from_git` and consumed by `write_entry_files` to
decide create-vs-update for the sidecar and whether the `.md` still needs
its frontmatter stripped (lazy migration).
"""
entry: Entry
md_text: str
md_sha: str
sidecar_text: str | None
sidecar_sha: str | None
malformed: bool
async def read_entry_from_git(
gitea: Any, org: str, repo: str, md_path: str, *, ref: str = "main"
) -> "EntryGitState | None":
"""Dual-read an entry from git → `EntryGitState`, or None if the `.md` is
missing. Reads the `.md` and its sibling sidecar (if any); never raises on
bad metadata (INV-3)."""
md = await gitea.read_file(org, repo, md_path, ref=ref)
if md is None:
return None
md_text, md_sha = md
sc_path = sidecar_path_for(md_path)
sc = await gitea.read_file(org, repo, sc_path, ref=ref)
sidecar_text, sidecar_sha = (sc[0], sc[1]) if sc else (None, None)
stem = md_path.rsplit("/", 1)[-1][: -len(".md")]
entry, malformed = read_entry(md_text, sidecar_text, fallback_slug=stem)
return EntryGitState(
entry=entry, md_text=md_text, md_sha=md_sha,
sidecar_text=sidecar_text, sidecar_sha=sidecar_sha, malformed=malformed,
)
def _md_has_frontmatter(md_text: str) -> bool:
return entry_mod.FRONTMATTER_RE.match(md_text) is not None
def write_entry_files(
md_path: str, entry: Entry, state: "EntryGitState"
) -> list[dict[str, Any]]:
"""Produce `change_files` ops that persist `entry`'s metadata to its sidecar
and keep the `.md` as pure prose (INV-1/INV-2).
- Sidecar: `create` when none existed, else `update` at its prior sha.
- `.md`: rewritten body-only **only when it still carries frontmatter**
(lazy migration, INV-6); an already-clean body is left untouched.
"""
sc_path = sidecar_path_for(md_path)
ops: list[dict[str, Any]] = []
sc_op: dict[str, Any] = {
"operation": "update" if state.sidecar_sha else "create",
"path": sc_path,
"content": sidecar_yaml(entry),
}
if state.sidecar_sha:
sc_op["sha"] = state.sidecar_sha
ops.append(sc_op)
if _md_has_frontmatter(state.md_text):
body = strip_frontmatter(state.md_text)
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
ops.append({
"operation": "update", "path": md_path,
"content": new_md, "sha": state.md_sha,
})
return ops
# ----- frontmatter -> sidecar migration (PUC-5) -----
async def migrate_collection(
gitea: Any,
*,
org: str,
repo: str,
subfolder: str = "",
actor: Any = None,
branch: str = "main",
) -> dict[str, Any]:
"""Migrate a collection's legacy-frontmatter entries to sidecars.
Walks `<subfolder>/rfcs`; for each `<slug>.md` that has legacy frontmatter
and **no** `<slug>.meta.yaml` sibling yet, it stages two file changes
create the sidecar (the entry's metadata, unknown keys preserved, INV-7)
and rewrite the `.md` to body-only (INV-2) and commits all of them in a
single ChangeFiles commit (§6.5: one commit per collection).
Idempotent: an entry that already has a sidecar is skipped; a second run
with nothing left to migrate makes no commit. Returns
`{"migrated": [...], "skipped": [...], "committed": bool}`.
"""
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
listing = await gitea.list_dir(org, repo, rfcs_dir, ref=branch)
names = {f.get("name") for f in listing if f.get("type") == "file"}
ops: list[dict[str, Any]] = []
migrated: list[str] = []
skipped: list[str] = []
for f in listing:
if f.get("type") != "file" or not f.get("name", "").endswith(".md"):
continue
slug = f["name"][:-len(".md")]
if sidecar_name(slug) in names:
skipped.append(slug) # already migrated
continue
result = await gitea.read_file(org, repo, f["path"], ref=branch)
if not result:
continue
text, sha = result
try:
e = entry_mod.parse(text)
except ValueError:
# No frontmatter to lift (e.g. an already-clean body without a
# sidecar) — nothing to migrate; leave it untouched.
skipped.append(slug)
continue
body = strip_frontmatter(text)
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
ops.append({
"operation": "create",
"path": f"{rfcs_dir}/{sidecar_name(slug)}",
"content": sidecar_yaml(e),
})
ops.append({
"operation": "update",
"path": f["path"],
"content": new_md,
"sha": sha,
})
migrated.append(slug)
committed = False
if ops:
n = len(migrated)
message = f"Migrate {n} entr{'y' if n == 1 else 'ies'} to metadata sidecars (§22.4a)"
kwargs: dict[str, Any] = {}
if actor is not None:
kwargs = {
"author_name": actor.display_name,
"author_email": actor.email or f"{actor.gitea_login}@users.noreply",
}
await gitea.change_files(
org, repo, files=ops, message=message, branch=branch, **kwargs
)
committed = True
return {"migrated": migrated, "skipped": skipped, "committed": committed}
+147
View File
@@ -0,0 +1,147 @@
"""§22.4a configurable collection metadata — field schema + central validation.
SLICE-2 of docs/design/2026-06-06-configurable-collection-metadata.md.
A collection declares a small **field schema** in its `.collection.yaml`
(`fields:` block) so its entries can carry structured metadata priority, tags,
and any custom fields the deployment defines. This module is the **one place**
that knows a collection's field shapes (modeled on `registry.py`):
- `parse_fields` normalize + validate the declared schema, leniently: a bad
block or a bad field def is skipped with a warning, never raised, so a typo
in one field can't nuke the collection mirror (INV-3 spirit). The normalized
schema is a plain, JSON-serializable mapping that rides in
`collections.config_json` (no DB migration) and is served verbatim by the
collection API.
- `validate` check an entry's stored values against the schema, returning a
list of advisory `Problem`s. Empty list = clean. Used **advisory at read**
(the corpus mirror flags a non-empty result as `metadata_malformed`, INV-3)
and is the enforcement point at the **write** boundary (the metadata-edit
endpoints land in SLICE-4/5).
Field types (v1): `enum` (single scalar, controlled by a required `values:`
list), `tags` (a list; free-form unless `values:` given), `text` (a free
string). `ref` / `multi-enum` are future (design §2, Q2). Unknown types are
ignored with a warning. Keys an entry carries that the schema does **not**
declare ride along untouched and are never flagged (INV-7).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
log = logging.getLogger(__name__)
# §6.3 v1 field types. `ref` (typed cross-entry link) and `multi-enum` are
# deferred (design §2, Q2) — declared with an unknown type they're skipped.
VALID_FIELD_TYPES = {"enum", "tags", "text"}
@dataclass
class Problem:
"""One advisory schema-validation problem against a declared field.
`code` is a stable machine token (`not-in-values`, `wrong-type`); `message`
is human-facing (surfaced at the write boundary in SLICE-4/5)."""
field: str
code: str
message: str
def as_dict(self) -> dict[str, str]:
return {"field": self.field, "code": self.code, "message": self.message}
# ----- schema parsing (lenient) -----
def parse_fields(raw: Any) -> dict[str, dict]:
"""Normalize a `.collection.yaml` `fields:` block → `{name: {type, ...}}`.
Pure (no I/O). Lenient (INV-3): a non-mapping block yields `{}`; an
individual field def that is not a mapping, has an unknown/missing `type`, or
is an `enum` without a non-empty `values:` list is **skipped with a warning**
never raised. Order is preserved (facet display order, SLICE-3). The
result is plain dicts so it serializes straight into `config_json` and the
collection API.
"""
if not isinstance(raw, dict):
if raw is not None:
log.warning("metadata_schema: fields block is not a mapping (%s); ignoring",
type(raw).__name__)
return {}
out: dict[str, dict] = {}
for name, spec in raw.items():
if not isinstance(spec, dict):
log.warning("metadata_schema: field %r def is not a mapping; skipping", name)
continue
ftype = str(spec.get("type") or "").strip()
if ftype not in VALID_FIELD_TYPES:
log.warning("metadata_schema: field %r has unknown type %r; skipping",
name, ftype)
continue
values = spec.get("values")
norm_values: list[str] | None = None
if values is not None:
if not isinstance(values, list):
log.warning("metadata_schema: field %r values is not a list; ignoring",
name)
else:
norm_values = [str(v) for v in values]
if ftype == "enum" and not norm_values:
log.warning("metadata_schema: enum field %r needs a non-empty values "
"list; skipping", name)
continue
field_def: dict[str, Any] = {"type": ftype}
if norm_values is not None:
field_def["values"] = norm_values
label = spec.get("label")
if label:
field_def["label"] = str(label)
out[str(name)] = field_def
return out
# ----- value validation (advisory) -----
def _is_scalar(v: Any) -> bool:
return isinstance(v, (str, int, float, bool))
def validate(values: dict[str, Any], fields: dict[str, dict]) -> list[Problem]:
"""Check an entry's metadata `values` against a collection's field schema.
Returns advisory `Problem`s (empty = clean). Only **declared** fields are
checked; a field the entry omits is fine (no required fields in v1), and a
key the schema doesn't declare rides along untouched (INV-7). With an empty
schema, everything is clean (INV-5). Never raises (INV-3).
"""
problems: list[Problem] = []
for name, spec in fields.items():
if name not in values:
continue
value = values[name]
if value is None:
continue
ftype = spec.get("type")
allowed = spec.get("values")
if ftype == "enum":
if not _is_scalar(value):
problems.append(Problem(name, "wrong-type",
f"{name!r} must be a single value, got {type(value).__name__}"))
elif allowed is not None and str(value) not in allowed:
problems.append(Problem(name, "not-in-values",
f"{name!r} value {value!r} is not one of {allowed}"))
elif ftype == "tags":
if not isinstance(value, list):
problems.append(Problem(name, "wrong-type",
f"{name!r} must be a list, got {type(value).__name__}"))
elif allowed is not None:
for member in value:
if str(member) not in allowed:
problems.append(Problem(name, "not-in-values",
f"{name!r} value {member!r} is not one of {allowed}"))
elif ftype == "text":
if not _is_scalar(value):
problems.append(Problem(name, "wrong-type",
f"{name!r} must be a string, got {type(value).__name__}"))
return problems
+62 -6
View File
@@ -38,22 +38,78 @@ from . import db, funder
from .providers import BaseProvider
def _models_from_config(config_json: str | None) -> list[str] | None:
"""The `enabled_models` list inside a project/collection `config_json`,
or None when the key is absent (meaning "no narrowing at this tier")."""
if not config_json:
return None
try:
cfg = json.loads(config_json)
except (json.JSONDecodeError, TypeError):
return None
em = cfg.get("enabled_models") if isinstance(cfg, dict) else None
return [str(m) for m in em] if isinstance(em, list) else None
def _narrow(universe: list[str], allowed: list[str] | None) -> list[str]:
"""Intersect `universe` with `allowed`, preserving universe order. `allowed`
None means no narrowing at this tier; an empty list narrows to empty (an
opt-out), exactly like the §6.6 per-entry `models: []`."""
if allowed is None:
return universe
allow = set(allowed)
return [k for k in universe if k in allow]
def _scope_narrowed_universe(
collection_id: str | None, operator_keys: list[str]
) -> list[str]:
"""§22.12 — narrow the operator (deployment) universe by the entry's
project then its collection `enabled_models`. Each tier may only narrow;
a missing config at a tier is a no-op. The collection cannot widen its
project because narrowing composes from the operator ceiling downward."""
if collection_id is None:
return list(operator_keys)
conn = db.conn()
crow = conn.execute(
"SELECT project_id, config_json FROM collections WHERE id = ?",
(collection_id,),
).fetchone()
universe = list(operator_keys)
if crow is None:
return universe
prow = conn.execute(
"SELECT config_json FROM projects WHERE id = ?", (crow["project_id"],)
).fetchone()
universe = _narrow(universe, _models_from_config(prow["config_json"] if prow else None))
universe = _narrow(universe, _models_from_config(crow["config_json"]))
return universe
def resolve_models_for_rfc(
slug: str, providers: dict[str, BaseProvider]
) -> list[str]:
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7.
"""Return the per-RFC resolved model keys per §6.6, extended by §6.7 and
§22.12.
The first entry is the RFC's default model. An empty list means
AI is unavailable on this RFC and callers refuse the AI surface.
"""
# §6.7: the funder universe (if any) replaces the operator universe
# as the base set the §6.6 frontmatter intersects against.
funder_universe = funder.resolve_funder_universe(slug, providers)
base_universe = funder_universe if funder_universe is not None else list(providers.keys())
row = db.conn().execute(
"SELECT models_json FROM cached_rfcs WHERE slug = ?",
"SELECT collection_id, models_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
collection_id = row["collection_id"] if row is not None else None
# §22.12: first narrow the operator universe by the entry's project +
# collection enabled_models (the deployment → project → collection chain).
scope_universe = _scope_narrowed_universe(collection_id, list(providers.keys()))
# §6.7: a consenting funder universe (if any) replaces the operator universe
# as the base set — still bounded by the §22.12 scope narrowing above.
funder_universe = funder.resolve_funder_universe(slug, providers)
if funder_universe is not None:
base_universe = _narrow(list(funder_universe), scope_universe)
else:
base_universe = scope_universe
if row is None or row["models_json"] is None:
return list(base_universe)
try:
+291
View File
@@ -270,6 +270,217 @@ def fan_out_new_beta_request(
)
def fan_out_contribution_request(
*,
rfc_slug: str,
requester_user_id: int,
request_id: int,
matched_term: str,
who_i_am: str,
why: str,
use_case: str | None,
) -> list[int]:
"""Roadmap #28 Part 3: a reader asked to contribute to a pending
(super-draft) RFC. Land one actionable notification per owner and
return their ids (the caller stamps the first onto the request row as
the inbox-action handle).
Personal-direct: the owner is the named subject of the request, so the
§15.4 email gate consults `email_personal_direct` exactly as for the
other owner-facing personal events no new preference column is
needed. The request's three free-text fields ride along in the payload
so the inbox row can show the full ask inline without a second fetch.
Actor is the requester per §15.9.
"""
requester = db.conn().execute(
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
).fetchone()
display = (requester["display_name"] if requester else None) or "Someone"
details = {
"request_id": request_id,
"matched_term": matched_term,
"requester_user_id": requester_user_id,
"requester_display": display,
"who_i_am": who_i_am,
"why": why,
"use_case": use_case or "",
}
notif_ids: list[int] = []
for recipient_id in _entry_owner_user_ids(rfc_slug):
if recipient_id == requester_user_id:
continue
notif_ids.append(
_emit_one(
recipient_user_id=recipient_id,
event_kind="contribution_request_on_pending_rfc",
category=CATEGORY_PERSONAL,
actor_user_id=requester_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details=details,
)
)
return notif_ids
def notify_scope_role_granted(
*,
recipient_user_id: int,
granter_user_id: int | None,
scope_type: str,
scope_id: str,
role: str,
project_id: str | None,
project_name: str | None,
collection_name: str | None,
) -> int | None:
"""§22 S4 (C.2): a scope Owner granted `recipient` a role at a scope.
Personal-direct the recipient is the named subject so it rides the
`email_personal_direct` gate like the other owner-facing personal events.
The scope facts ride in the payload so the inbox row (and email body) names
the project and role without a second fetch. Actor is the granter (§15.9);
a system/administrative grant with no granter renders as "the app".
Returns the notification id, or None when the grantee would be notifying
themselves (a self-grant no notification)."""
if granter_user_id is not None and recipient_user_id == granter_user_id:
return None
details = {
"scope_type": scope_type,
"scope_id": scope_id,
"role": role,
"project_id": project_id or "",
"project_name": project_name or "",
"collection_name": collection_name or "",
}
return _emit_one(
recipient_user_id=recipient_user_id,
event_kind="scope_role_granted",
category=CATEGORY_PERSONAL,
actor_user_id=granter_user_id,
rfc_slug=None,
branch_name=None,
pr_number=None,
details=details,
)
def notify_contribution_decided(
*,
rfc_slug: str,
requester_user_id: int,
decider_user_id: int,
request_id: int,
accepted: bool,
) -> None:
"""Roadmap #28 Part 3: tell the requester an owner accepted or declined
their contribute request. On accept the requester also receives the
#12 invitation email out-of-band; this inbox row is the in-app echo
that points them at it."""
_emit_one(
recipient_user_id=requester_user_id,
event_kind=(
"contribution_request_accepted" if accepted else "contribution_request_declined"
),
category=CATEGORY_PERSONAL,
actor_user_id=decider_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details={"request_id": request_id},
)
def fan_out_join_request(
*,
scope_type: str,
scope_id: str,
scope_name: str | None,
project_id: str | None,
project_name: str | None,
requester_user_id: int,
request_id: int,
requested_role: str,
message: str | None,
) -> list[int]:
"""§22.8: a user asked to join a scope. Land one actionable notification per
Owner across the scope's subtree (the cross-collection inbox, §22.11) and
return their ids (the caller stamps the first onto the request row as the
inbox-action handle any of them can act on it).
Personal-direct: each Owner is a named subject able to act, so it rides the
`email_personal_direct` gate like the other owner-facing personal events. The
requested role + message ride in the payload so the inbox row shows the full
ask inline. Actor is the requester per §15.9.
"""
requester = db.conn().execute(
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
).fetchone()
display = (requester["display_name"] if requester else None) or "Someone"
details = {
"request_id": request_id,
"scope_type": scope_type,
"scope_id": scope_id,
"scope_name": scope_name or scope_id,
"project_id": project_id or "",
"project_name": project_name or "",
"requested_role": requested_role,
"requester_user_id": requester_user_id,
"requester_display": display,
"message": message or "",
}
notif_ids: list[int] = []
for recipient_id in _scope_owner_user_ids(scope_type, scope_id):
if recipient_id == requester_user_id:
continue
notif_ids.append(
_emit_one(
recipient_user_id=recipient_id,
event_kind="join_request_on_scope",
category=CATEGORY_PERSONAL,
actor_user_id=requester_user_id,
rfc_slug=None,
branch_name=None,
pr_number=None,
details=details,
)
)
return notif_ids
def notify_join_decided(
*,
requester_user_id: int,
decider_user_id: int,
request_id: int,
scope_type: str,
scope_id: str,
scope_name: str | None,
granted_role: str | None,
accepted: bool,
) -> None:
"""§22.8: tell the requester an Owner accepted (writing their `memberships`
row) or declined their request to join. The scope + granted role ride in the
payload so the inbox row names where they were let in without a second fetch."""
_emit_one(
recipient_user_id=requester_user_id,
event_kind=("join_request_accepted" if accepted else "join_request_declined"),
category=CATEGORY_PERSONAL,
actor_user_id=decider_user_id,
rfc_slug=None,
branch_name=None,
pr_number=None,
details={
"request_id": request_id,
"scope_type": scope_type,
"scope_id": scope_id,
"scope_name": scope_name or scope_id,
"granted_role": granted_role or "",
},
)
def fan_out_chat_message(
*,
actor_user_id: int,
@@ -545,6 +756,41 @@ def _admin_user_ids() -> set[int]:
}
def _scope_owner_user_ids(scope_type: str, scope_id: str) -> set[int]:
"""The Owners who administer a scope *across the subtree* (§22.8 / §22.11) —
the recipients of a request-to-join, aggregated upward so the request reaches
everyone who could grant it. For a `collection`: its collection-scope Owners,
its project's Owners, the global Owners, and deployment owners/admins. For a
`project`: its project-scope Owners plus global Owners and deployment
owners/admins. (Mirrors the upward fold in `auth.can_invite_at_*`.)"""
# Deployment owners/admins are global Owners by §B.1; explicit
# scope_type='global' Owner grants join them.
ids: set[int] = set(_admin_user_ids())
for r in db.conn().execute(
"SELECT user_id AS id FROM memberships WHERE scope_type = 'global' AND role = 'owner'"
):
ids.add(r["id"])
def _owners_at(stype: str, sid: str) -> None:
for r in db.conn().execute(
"SELECT user_id AS id FROM memberships "
"WHERE scope_type = ? AND scope_id = ? AND role = 'owner'",
(stype, sid),
):
ids.add(r["id"])
if scope_type == "collection":
_owners_at("collection", scope_id)
prow = db.conn().execute(
"SELECT project_id FROM collections WHERE id = ?", (scope_id,)
).fetchone()
if prow and prow["project_id"]:
_owners_at("project", prow["project_id"])
elif scope_type == "project":
_owners_at("project", scope_id)
return ids
def _proposer_user_id(rfc_slug: str) -> set[int]:
row = db.conn().execute(
"""
@@ -769,6 +1015,46 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
return f"{actor} began graduating {title}."
if event_kind == "pr_conflict_with_main":
return f"{actor} started a resolution branch on {title}."
if event_kind == "contribution_request_on_pending_rfc":
# Roadmap #28 Part 3: owner-facing, actionable. The term is the
# super-draft reference that surfaced the offer; the inbox row
# renders Accept/Decline beneath this line.
term = extras.get("matched_term") or title
return f"{actor} wants to contribute to your pending RFC for '{term}'."
if event_kind == "contribution_request_accepted":
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
if event_kind == "contribution_request_declined":
return f"{actor} declined your request to contribute to {title}."
if event_kind == "scope_role_granted":
# §22 S4 (C.2): names the role and the scope (the project, and the
# collection when collection-scoped) per "a §15 notification naming the
# project and role".
role_label = "Owner" if extras.get("role") == "owner" else "RFC Contributor"
project_label = extras.get("project_name") or extras.get("project_id") or "a project"
scope_type = extras.get("scope_type")
if scope_type == "collection":
col_label = extras.get("collection_name") or extras.get("scope_id") or "a collection"
return f"{actor} granted you {role_label} on collection {project_label}/{col_label}."
if scope_type == "global":
return f"{actor} granted you {role_label} across the whole deployment."
return f"{actor} granted you {role_label} on project {project_label}."
if event_kind == "join_request_on_scope":
# §22.8: owner-facing, actionable. Names who wants in, where, and as
# what; the inbox row renders Accept/Decline beneath this line.
role_label = "Owner" if extras.get("requested_role") == "owner" else "RFC Contributor"
scope_type = extras.get("scope_type")
scope_label = extras.get("scope_name") or extras.get("scope_id") or "a scope"
where = (
f"collection {scope_label}" if scope_type == "collection" else f"project {scope_label}"
)
return f"{actor} asked to join {where} as {role_label}."
if event_kind == "join_request_accepted":
role_label = "Owner" if extras.get("granted_role") == "owner" else "RFC Contributor"
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
return f"{actor} accepted your request to join {scope_label} — you're in as {role_label}."
if event_kind == "join_request_declined":
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
return f"{actor} declined your request to join {scope_label}."
if event_kind == "new_beta_request":
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
# requester) and the captured full name + email read as
@@ -884,6 +1170,11 @@ def list_inbox(
"read_at": row["read_at"],
"category": extras.get("category"),
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
# The row's payload, surfaced for kinds that render inline
# detail (e.g. #28 Part 3's contribute-request who/why/use-case
# + Accept/Decline). Safe to expose: a recipient only ever sees
# their own notifications.
"extras": extras,
})
if bundled:
+76
View File
@@ -85,6 +85,15 @@ def _cooldown_seconds() -> int:
return 60
# v0.25.0 / security audit 0026 (H1): per-email OTC verify lockout,
# mirroring the passcode path (passcode.py). Five consecutive wrong codes
# for an email lock its OTC verify for 15 minutes. The per-IP limiter in
# ratelimit.py is the primary brute-force brake; this is the durable,
# passcode-parity layer.
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
# ---------------------------------------------------------------------------
# Code generation + hashing
# ---------------------------------------------------------------------------
@@ -206,6 +215,61 @@ class VerifyOutcome:
ok: bool
user: SessionUser | None
reason: str
# v0.25.0 (H1): ISO-8601 stamp when reason == 'locked'.
locked_until: str | None = None
def _verify_lockout_until(email: str) -> str | None:
"""Return the active lockout stamp for `email`, or None if not locked.
Clears an elapsed lockout (and resets the counter) as a side effect so
the next failure starts a fresh budget mirrors passcode.verify_passcode.
"""
row = db.conn().execute(
"SELECT failed_attempts, locked_until FROM otc_verify_state WHERE email = ?",
(email,),
).fetchone()
if row is None or not row["locked_until"]:
return None
still_locked = db.conn().execute(
"SELECT datetime(?) > datetime('now') AS locked", (row["locked_until"],),
).fetchone()["locked"]
if still_locked:
return row["locked_until"]
db.conn().execute(
"UPDATE otc_verify_state SET failed_attempts = 0, locked_until = NULL WHERE email = ?",
(email,),
)
return None
def _record_verify_failure(email: str) -> None:
"""Increment the per-email failure counter; stamp a lockout once it
crosses the threshold. Mirrors the passcode lockout shape."""
db.conn().execute(
"""
INSERT INTO otc_verify_state (email, failed_attempts)
VALUES (?, 1)
ON CONFLICT(email) DO UPDATE SET failed_attempts = failed_attempts + 1
""",
(email,),
)
count = db.conn().execute(
"SELECT failed_attempts FROM otc_verify_state WHERE email = ?", (email,),
).fetchone()["failed_attempts"]
if count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
db.conn().execute(
f"""
UPDATE otc_verify_state
SET locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
WHERE email = ?
""",
(email,),
)
def _clear_verify_state(email: str) -> None:
db.conn().execute("DELETE FROM otc_verify_state WHERE email = ?", (email,))
def verify_code(email: str, code: str) -> VerifyOutcome:
@@ -214,6 +278,13 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
if not email or not code:
return VerifyOutcome(ok=False, user=None, reason="invalid")
# v0.25.0 (H1): refuse before spending any bcrypt if this email is in
# its OTC-verify lockout window. The passcode path is unaffected — a
# locked-out OTC user can still set/use a passcode, and vice versa.
locked_until = _verify_lockout_until(email)
if locked_until:
return VerifyOutcome(ok=False, user=None, reason="locked", locked_until=locked_until)
rows = db.conn().execute(
"""
SELECT id, code_hash, expires_at, consumed_at
@@ -237,6 +308,9 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
break
if matched is None:
# A genuine wrong guess against this email — the brute-force
# signal. Count it toward the lockout threshold (H1).
_record_verify_failure(email)
return VerifyOutcome(ok=False, user=None, reason="wrong")
if matched["consumed_at"] is not None:
@@ -255,6 +329,8 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
# Success wipes the per-email failure counter (H1).
_clear_verify_state(email)
user = provision_or_link_user(email)
return VerifyOutcome(ok=True, user=user, reason="ok")
+211
View File
@@ -0,0 +1,211 @@
"""Project registry — the §22 multi-project layer.
A deployment hosts one or more projects (§22.1). The git registry mirror
that lets a deployment declare projects lands in M3 and drives this module.
`seed_default_project` (the §22.13 META_REPO backfill) is retired in M3;
the registry mirror (`registry.refresh_registry`) is authoritative.
"""
from __future__ import annotations
import logging
from . import db
from .config import Config
log = logging.getLogger(__name__)
DEFAULT_PROJECT_ID = "default"
def resolved_default_id(config: Config) -> str:
"""The id of the deployment's bootstrap/default project. Plan A: always
'default' (the re-stamp to a config slug rides Plan B). The config knob is
read here so Plan B can flip the resolution without touching call sites."""
return config.default_project_id.strip() or DEFAULT_PROJECT_ID
def restamp_default_project(config: Config) -> None:
"""§22.13 step 1 — one-time rename of the M1 bootstrap project id
(DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id
(the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original
corpus lands at a meaningful `/p/<id>/` and `default` is never a public URL.
Renames `project_id` across every project-scoped table (discovered by
column, so it stays correct as the schema grows), then drops the stale
bootstrap `projects` row (its data has moved to the configured row, which
the registry mirror already created). Idempotent and a no-op when the
configured id is still 'default' or no bootstrap rows remain. Runs at
startup after the registry mirror, with FK enforcement off for the rename
(the composite FKs are kept consistent because parent and child rows are
renamed together) and a foreign_key_check backstop before commit.
"""
target = resolved_default_id(config)
if target == DEFAULT_PROJECT_ID:
return
conn = db.conn()
# §22 three-tier: the entry-corpus tables key on collection_id now; detect a
# lingering bootstrap project by the project-grain `collections.project_id`
# (the PRAGMA scan below still renames every project_id column dynamically).
has_rows = conn.execute(
"SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
).fetchone()
stale_proj = conn.execute(
"SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
).fetchone()
if not has_rows and not stale_proj:
return
if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None:
log.warning("restamp: target project %r not in registry yet; skipping", target)
return
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
pid_tables = [
t for t in tables
if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})"))
]
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.execute("BEGIN")
for t in pid_tables:
conn.execute(
f"UPDATE {t} SET project_id = ? WHERE project_id = ?",
(target, DEFAULT_PROJECT_ID),
)
# The bootstrap row's data has moved to the configured (registry) row.
conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,))
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
conn.execute("ROLLBACK")
raise RuntimeError(
f"restamp left foreign-key violations: {[tuple(v) for v in violations]}"
)
conn.execute("COMMIT")
except Exception:
try:
conn.execute("ROLLBACK")
except Exception:
pass
raise
finally:
conn.execute("PRAGMA foreign_keys = ON")
log.info("restamp: renamed bootstrap project %r -> %r across %d tables",
DEFAULT_PROJECT_ID, target, len(pid_tables))
def reconcile_default_collection_id(config: Config) -> None:
"""Heal the §22 migration-029 vs registry-mirror divergence for the default
project's collection id on a multi-project deployment.
Migration 029 seeds each project's default collection id as the literal
'default' only when the DB holds a single project at migration time; with
2 projects it falls back to the *project id* (avoiding a PK collision
029 can't read DEFAULT_PROJECT_ID, there is no env in SQL). But the registry
mirror (`registry._default_collection_id`) expects the deployment's default
project to own the collection id 'default'. On an upgrade whose DB already
held 2 projects when 029 ran, the default project's collection is therefore
named after the project (e.g. 'ohm'), and the next mirror would INSERT a
second, empty 'default' collection instead of merging duplicating the
default corpus and orphaning the entries (which point at 'ohm').
This is the collection-grain twin of `restamp_default_project`. Run at
startup BEFORE the registry mirror so the canonical 'default' collection
already exists when the mirror upserts (merge, not duplicate). Renames the
divergent collection's id to 'default' and cascades `collection_id` across
every collection-keyed table, with FK enforcement off for the atomic rename
and a `foreign_key_check` backstop before commit. Idempotent; a no-op on
fresh / single-project / already-aligned deployments.
"""
from .collections import DEFAULT_COLLECTION_ID
target = resolved_default_id(config)
if target == DEFAULT_COLLECTION_ID: # default project already owns 'default'
return
conn = db.conn()
# The 029 ≥2-projects seed names the default project's collection after the
# project itself; the canonical id the mirror expects is 'default'.
divergent = conn.execute(
"SELECT 1 FROM collections WHERE id = ? AND project_id = ? LIMIT 1",
(target, target),
).fetchone()
if not divergent:
return
if conn.execute(
"SELECT 1 FROM collections WHERE id = ? LIMIT 1", (DEFAULT_COLLECTION_ID,)
).fetchone():
# A 'default' collection already exists (e.g. a prior buggy mirror left a
# duplicate). Don't auto-merge data — that needs care; leave both for
# operator cleanup and log loudly.
log.warning(
"reconcile: default project %r owns both a %r and a 'default' "
"collection; skipping auto-rename (manual merge required)",
target, target,
)
return
cid_tables = [
t["name"]
for t in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if any(c["name"] == "collection_id"
for c in conn.execute(f"PRAGMA table_info({t['name']})"))
]
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.execute("BEGIN")
conn.execute(
"UPDATE collections SET id = ? WHERE id = ?",
(DEFAULT_COLLECTION_ID, target),
)
for t in cid_tables:
conn.execute(
f"UPDATE {t} SET collection_id = ? WHERE collection_id = ?",
(DEFAULT_COLLECTION_ID, target),
)
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
conn.execute("ROLLBACK")
raise RuntimeError(
f"reconcile left foreign-key violations: {[tuple(v) for v in violations]}"
)
conn.execute("COMMIT")
except Exception:
try:
conn.execute("ROLLBACK")
except Exception:
pass
raise
finally:
conn.execute("PRAGMA foreign_keys = ON")
log.info(
"reconcile: renamed default-project collection %r -> 'default' across %d tables",
target, len(cid_tables),
)
def default_content_repo(config: Config) -> str | None:
"""The content repo the single-corpus mirror reads, from the default
project's row (filled by the registry mirror). Replaces the retired
META_REPO. None until the registry mirror has run."""
row = db.conn().execute(
"SELECT content_repo FROM projects WHERE id = ?",
(resolved_default_id(config),),
).fetchone()
return row["content_repo"] if row and row["content_repo"] else None
def content_repo(project_id: str) -> str | None:
"""The content repo for a specific project (§22.3). None if unknown/unset.
The per-project successor to `default_content_repo` for the write path."""
row = db.conn().execute(
"SELECT content_repo FROM projects WHERE id = ?", (project_id,)
).fetchone()
return row["content_repo"] if row and row["content_repo"] else None
def project_initial_state(project_id: str) -> str:
"""§22.4b landing state for new entries in a project's default collection
(the per-corpus field moved down to the collection in migration 029).
Defaults to 'super-draft' for an unknown/unset row (today's-flow default)."""
from . import collections as collections_mod
return collections_mod.collection_initial_state(
collections_mod.default_collection_id(project_id)
)
+15
View File
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
return providers
def construct_haiku(api_key: str) -> AnthropicProvider:
"""A dedicated Claude Haiku provider, independent of the
`ENABLED_MODELS` chat-picker universe.
The §9.1 tag-suggestion surface (roadmap #27) always wants the
cheap + fast model regardless of which models the operator surfaces
in the §8.12 picker, so it constructs Haiku directly from the
operator's Anthropic key rather than going through `load_providers`.
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
picker uses, so a model-string bump lands in one place.
"""
model, name = _CLAUDE_VARIANTS["claude-haiku"]
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
def load_from_config(config) -> dict[str, BaseProvider]:
"""Convenience adapter so callers can pass our Config dataclass directly."""
env = {
+111
View File
@@ -0,0 +1,111 @@
"""In-process per-IP sliding-window rate limiter (security audit 0026, H1).
The auth verify endpoints (`/auth/otc/verify`, `/auth/passcode/verify`)
had no per-IP brake, so an attacker could fan out guesses against a
target identity bounded only by bcrypt cost. This module is the brake.
It is deliberately tiny: §4.2 says the app is a single process with a
colocated SQLite file, so an in-memory dict of `key -> deque[timestamps]`
is sufficient and needs no shared store. State resets on restart, which
fails *open* for a brief window acceptable because the per-email OTC
lockout (`otc_verify_state`) and the passcode lockout both persist in the
database and carry the durable guarantee; this limiter is the
anti-fan-out layer on top.
Chosen over a per-identity lockout *as the primary control* because a
per-IP window throttles the attacker without letting them grief a victim
by locking that victim's account (the known downside of identity
lockouts). Both layers run together.
"""
from __future__ import annotations
import os
import threading
import time
from collections import defaultdict, deque
def _max_events(env_name: str, default: int) -> int:
"""Per-limiter budget, overridable via env (e.g. a test/PPE stack that
drives the auth endpoints repeatedly from one IP). Production leaves these
unset and gets the secure defaults below. A non-positive / unparseable
value falls back to the default."""
raw = os.environ.get(env_name, "").strip()
if not raw:
return default
try:
n = int(raw)
except ValueError:
return default
return n if n > 0 else default
class SlidingWindowLimiter:
"""Allow at most `max_events` per `window_seconds` per key.
`allow(key)` records an event and returns True if the key is still
within budget, False if it has exceeded it. Timestamps use a
monotonic clock so the limiter is immune to wall-clock jumps.
"""
def __init__(self, max_events: int, window_seconds: float) -> None:
self.max_events = max_events
self.window_seconds = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def allow(self, key: str) -> bool:
now = time.monotonic()
cutoff = now - self.window_seconds
with self._lock:
q = self._events[key]
while q and q[0] < cutoff:
q.popleft()
if len(q) >= self.max_events:
return False
q.append(now)
# Opportunistic cleanup so idle keys don't accumulate forever.
if not q:
self._events.pop(key, None)
return True
def reset(self, key: str) -> None:
"""Drop a key's window — e.g. after a successful sign-in so a
legitimate user who fat-fingered a few times isn't throttled."""
with self._lock:
self._events.pop(key, None)
# Module-level limiters shared across requests (one process, so module
# state is the natural home). Tunables are intentionally generous enough
# not to bother a human retyping a code, tight enough to kill fan-out:
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
# this is defense in depth against a solved-challenge replay loop).
verify_limiter = SlidingWindowLimiter(
max_events=_max_events("RATELIMIT_VERIFY_MAX", 10), window_seconds=300)
otc_request_limiter = SlidingWindowLimiter(
max_events=_max_events("RATELIMIT_OTC_REQUEST_MAX", 5), window_seconds=300)
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
# It's a legitimate Login-flow affordance, so the budget is generous —
# enough for a human typing emails, tight enough to stop bulk scraping.
check_limiter = SlidingWindowLimiter(
max_events=_max_events("RATELIMIT_CHECK_MAX", 30), window_seconds=300)
def _reset_all_for_tests() -> None:
"""Clear every module-level limiter's window. Test support only — the
limiters are process-global singletons, so without a per-test reset
one test's requests bleed into the next and later tests trip the
budget (429). Not called in production."""
for lim in (verify_limiter, otc_request_limiter, check_limiter):
with lim._lock:
lim._events.clear()
def client_key(request) -> str:
"""Best-effort client identity for limiting. Behind nginx the app is
started with `--forwarded-allow-ips 127.0.0.1`, so `request.client.host`
reflects the real client IP via Uvicorn's ProxyHeaders handling."""
client = getattr(request, "client", None)
return client.host if client and client.host else "unknown"
+358
View File
@@ -0,0 +1,358 @@
"""§22.2 project registry mirror — the config-side analogue of
cache.refresh_meta_repo.
A deployment declares its projects in a `projects.yaml` at the root of
REGISTRY_REPO. This module mirrors that file into the `projects` cache table
and the `deployment` singleton. Per §22.2, `projects` rows flow from the
registry only never from user actions. The mirror runs on the registry-repo
webhook and on every reconciler sweep (Option A wiring, Task 5).
"""
from __future__ import annotations
import base64
import json
import logging
import re
from dataclasses import dataclass, field
import yaml
from . import db
from . import metadata_schema
from .config import Config
from .gitea import Gitea
log = logging.getLogger(__name__)
VALID_TYPES = {"document", "specification", "bdd"}
VALID_VISIBILITY = {"gated", "public", "unlisted"}
VALID_INITIAL_STATE = {"super-draft", "active"}
# §22.4b: per-type default landing state.
_TYPE_DEFAULT_INITIAL_STATE = {
"document": "super-draft",
"specification": "super-draft",
"bdd": "active",
}
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
class RegistryError(Exception):
"""A registry document that fails validation, or a missing registry file.
Raised by parse_registry/refresh_registry. The caller decides severity:
fatal at startup (no last-good to serve), tolerated on a running deployment
(keep the last-good projects rows). See Task 5 wiring.
"""
@dataclass
class ProjectEntry:
id: str
name: str
type: str
content_repo: str
visibility: str
initial_state: str
config: dict = field(default_factory=dict) # theme, enabled_models
@dataclass
class CollectionEntry:
"""A named collection declared by a `.collection.yaml` manifest inside a
project's content repo (S2). `visibility=None` means "inherit the project's
visibility"."""
type: str
visibility: str | None
initial_state: str
name: str | None
config: dict = field(default_factory=dict) # §22.12 enabled_models
@dataclass
class RegistryDoc:
deployment_name: str
deployment_tagline: str
projects: list[ProjectEntry]
def parse_registry(text: str) -> RegistryDoc:
"""Parse + validate projects.yaml. Pure (no I/O). Raises RegistryError."""
raw = yaml.safe_load(text) or {}
dep = raw.get("deployment") or {}
projects_raw = raw.get("projects") or []
if not isinstance(projects_raw, list) or not projects_raw:
raise RegistryError("registry must declare at least one project")
seen: set[str] = set()
entries: list[ProjectEntry] = []
for p in projects_raw:
if not isinstance(p, dict):
raise RegistryError(f"each project entry must be a mapping, got {type(p).__name__}")
pid = str(p.get("id") or "").strip()
if not _SLUG_RE.match(pid):
raise RegistryError(f"project id {pid!r} is not a valid slug")
if pid in seen:
raise RegistryError(f"duplicate project id {pid!r}")
seen.add(pid)
name = str(p.get("name") or "").strip()
if not name:
raise RegistryError(f"project {pid!r} missing name")
ptype = str(p.get("type") or "").strip()
if ptype not in VALID_TYPES:
raise RegistryError(f"project {pid!r} has invalid type {ptype!r}")
content_repo = str(p.get("content_repo") or "").strip()
if not content_repo:
raise RegistryError(f"project {pid!r} missing content_repo")
vis = str(p.get("visibility") or "gated").strip()
if vis not in VALID_VISIBILITY:
raise RegistryError(f"project {pid!r} has invalid visibility {vis!r}")
initial_state = str(
p.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ptype]
).strip()
if initial_state not in VALID_INITIAL_STATE:
raise RegistryError(
f"project {pid!r} has invalid initial_state {initial_state!r}"
)
cfg: dict = {}
if p.get("theme") is not None:
cfg["theme"] = p["theme"]
if p.get("enabled_models") is not None:
cfg["enabled_models"] = [str(m) for m in p["enabled_models"]]
entries.append(
ProjectEntry(pid, name, ptype, content_repo, vis, initial_state, cfg)
)
return RegistryDoc(
deployment_name=str(dep.get("name") or "").strip(),
deployment_tagline=str(dep.get("tagline") or "").strip(),
projects=entries,
)
def parse_collection_manifest(text: str) -> CollectionEntry:
"""Parse + validate a `.collection.yaml`. Pure (no I/O). Raises RegistryError.
`type` is required and immutable (§22.4a, enforced at upsert). `visibility`
is optional omitted means inherit the project's. `initial_state` defaults
per type (§22.4b)."""
raw = yaml.safe_load(text) or {}
if not isinstance(raw, dict):
raise RegistryError("collection manifest must be a mapping")
ctype = str(raw.get("type") or "").strip()
if ctype not in VALID_TYPES:
raise RegistryError(f"collection has invalid type {ctype!r}")
vis = raw.get("visibility")
if vis is not None:
vis = str(vis).strip()
if vis not in VALID_VISIBILITY:
raise RegistryError(f"collection has invalid visibility {vis!r}")
initial_state = str(
raw.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ctype]
).strip()
if initial_state not in VALID_INITIAL_STATE:
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
name = raw.get("name")
name = str(name).strip() if name else None
# §22.12: an optional per-collection enabled_models list that narrows the
# project's universe. Absent → no narrowing (inherit). Present (incl. empty)
# → narrowing applies; [] opts the collection out of AI.
cfg: dict = {}
if raw.get("enabled_models") is not None:
em = raw["enabled_models"]
if not isinstance(em, list):
raise RegistryError("collection enabled_models must be a list")
cfg["enabled_models"] = [str(m) for m in em]
# §22.4a SLICE-2: the collection's metadata field schema. Parsed leniently —
# a bad field def is skipped with a warning, never fatal (INV-3), so a typo
# in one field can't drop the whole collection from the mirror. Stored only
# when at least one valid field survives.
fields = metadata_schema.parse_fields(raw.get("fields"))
if fields:
cfg["fields"] = fields
return CollectionEntry(ctype, vis, initial_state, name, cfg)
def _default_collection_id(project_id: str, default_id: str) -> str:
"""The id of a project's default collection. The deployment's primary
project (== `default_id`, the §22.13 resolved default) gets the stable
literal `'default'` matching migration 029's seed so the upsert *merges*
onto the migration-seeded row rather than duplicating it (critical on a
fresh deploy where the bootstrap `default` project is later restamped to the
configured id). Any additional project keys its default collection by its own
id, keeping the collection PK globally unique (pre-S5 multi-project)."""
return "default" if project_id == default_id else project_id
def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None:
"""Upsert the parsed registry into projects + their default collections +
the deployment singleton. Idempotent.
§22 three-tier (S1): a project carries the grouping-tier fields (name,
content_repo, visibility, config); the per-corpus fields (`type`,
`initial_state`) live on the project's default collection. §22.4a: `type` is
immutable a change against an existing collection is rejected (skip the
type change + log), never applied. Projects absent from the registry are
left in place (archival is out of scope for M3; they stop refreshing).
"""
with db.tx() as conn:
for e in doc.projects:
cid = _default_collection_id(e.id, default_id)
existing = conn.execute(
"SELECT type FROM collections WHERE id = ?", (cid,)
).fetchone()
type_locked = existing is not None and existing["type"] != e.type
if type_locked:
log.error(
"registry: refusing immutable type change on collection %s (%s -> %s)",
cid, existing["type"], e.type,
)
# The project (grouping tier) always refreshes.
conn.execute(
"""
INSERT INTO projects
(id, name, content_repo, visibility, config_json, registry_sha, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
content_repo = excluded.content_repo,
visibility = excluded.visibility,
config_json = excluded.config_json,
registry_sha = excluded.registry_sha,
updated_at = datetime('now')
""",
(e.id, e.name, e.content_repo, e.visibility, json.dumps(e.config), registry_sha),
)
# The default collection (corpus tier). On an immutable-type
# conflict, keep the stored type but still refresh the rest.
effective_type = existing["type"] if type_locked else e.type
conn.execute(
"""
INSERT INTO collections
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
VALUES (?, ?, ?, '', ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id,
type = excluded.type,
initial_state = excluded.initial_state,
visibility = excluded.visibility,
name = excluded.name,
registry_sha = excluded.registry_sha,
updated_at = datetime('now')
""",
(cid, e.id, effective_type, e.initial_state, e.visibility, e.name, registry_sha),
)
conn.execute(
"""
UPDATE deployment
SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now')
WHERE id = 1
""",
(doc.deployment_name, doc.deployment_tagline, registry_sha),
)
def _strictest_visibility(a: str, b: str) -> str:
"""The stricter of two §22.5 visibilities on the public-exposure axis
(`public` < `unlisted` < `gated`). Used to enforce that a collection is set
only as strict or stricter than its project (S3 operator decision)."""
rank = {"public": 0, "unlisted": 1, "gated": 2}
return a if rank.get(a, 2) >= rank.get(b, 2) else b
def _upsert_named_collection(
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
) -> None:
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type
change against an existing row is refused (logged, not applied). A None
manifest visibility inherits the project's visibility; a manifest that tries
to be *looser* than its project is clamped to the project's (S3 strictness:
a collection may narrow but never widen its project's visibility)."""
requested = ce.visibility or proj.visibility
visibility = _strictest_visibility(requested, proj.visibility)
if visibility != requested:
log.warning(
"registry: collection %s visibility %r looser than project %s %r"
"clamped to %r (S3 strictness)",
subdir, requested, proj.id, proj.visibility, visibility,
)
with db.tx() as conn:
existing = conn.execute(
"SELECT type FROM collections WHERE id = ?", (subdir,)
).fetchone()
if existing is not None and existing["type"] != ce.type:
log.error(
"registry: refusing immutable type change on collection %s (%s -> %s)",
subdir, existing["type"], ce.type,
)
return
conn.execute(
"""
INSERT INTO collections
(id, project_id, type, subfolder, initial_state, visibility, name, config_json, registry_sha, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id,
initial_state = excluded.initial_state,
visibility = excluded.visibility,
name = excluded.name,
config_json = excluded.config_json,
registry_sha = excluded.registry_sha,
updated_at = datetime('now')
""",
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name,
json.dumps(ce.config), sha),
)
async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryDoc, sha: str) -> None:
"""§22 S2: named collections are declared by `.collection.yaml` manifests
inside each project's content repo (the default collection comes from
projects.yaml). Walk each content repo root; a subdir carrying a manifest
becomes a collection keyed by the subdir name. Tolerant: a transport or
parse failure on one project/collection logs and is skipped, never aborts
the wider mirror (keep last-good)."""
for proj in doc.projects:
try:
items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main")
except Exception as e: # noqa: BLE001 — GiteaError/transport: tolerate
log.warning("registry: cannot list %s root: %s", proj.content_repo, e)
continue
for it in items:
if it.get("type") != "dir":
continue
subdir = it["name"]
manifest = await gitea.get_contents(
config.gitea_org, proj.content_repo, f"{subdir}/.collection.yaml", ref="main"
)
if not manifest or manifest.get("type") != "file":
continue
mtext = base64.b64decode(manifest["content"]).decode("utf-8")
try:
ce = parse_collection_manifest(mtext)
except RegistryError as e:
log.error("registry: bad manifest %s/%s: %s", proj.content_repo, subdir, e)
continue
_upsert_named_collection(proj, subdir, ce, sha)
async def refresh_registry(config: Config, gitea: Gitea) -> None:
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
on transport failure; the caller chooses fatal-vs-tolerated.
"""
item = await gitea.get_contents(
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
)
if not item or item.get("type") != "file":
raise RegistryError(
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
)
text = base64.b64decode(item["content"]).decode("utf-8")
# Prefer the file's last commit sha for provenance (production Gitea
# includes it on the contents response); fall back to the blob sha.
sha = item.get("last_commit_sha") or item.get("sha") or ""
doc = parse_registry(text)
from . import projects as projects_mod
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
# §22 S2: discover + upsert named collections from each content repo.
await _mirror_named_collections(config, gitea, doc, sha)
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
+333
View File
@@ -0,0 +1,333 @@
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
The scanner splits a plain-text PR description / comment body into a list
of *segments* the frontend renders: plain-text runs interleaved with
typed link segments. The backend never emits HTML the frontend maps
each segment onto a React node so the surface is XSS-safe by
construction and independent of any HTML-sanitization layer.
Three buckets, one scan (Parts 13):
* ``{"type": "rfc", ...}`` Part 1. The term matches an
**accepted** (``state='active'``) RFC; renders as a link to it.
* ``{"type": "rfc-pending", ...}`` Part 3. The term matches a
**pending** RFC a super-draft (``state='super-draft'``: accepted
as an idea but not yet graduated to an active RFC) which has an
owner and a contribution surface. Renders as an "ask to contribute"
affordance carrying the owner's display name.
* ``{"type": "rfc-candidate", ...}`` Part 2. The term is a
strong-candidate that does **not** yet have a defining RFC. Renders
(for a viewer with create rights) as a "create RFC for '<term>'"
affordance that pre-fills the propose flow.
Precedence at any position is active > pending > candidate, then
longest-match-first an active link always wins over a contribute offer
which always wins over a create offer for the same span.
**Read-time enrichment, not submit-time persistence** (unchanged from
Part 1): drafts are never scanned, only submitted content on the read
paths, so links/offers track the *live* corpus. The active-RFC corpus,
super-draft corpus, and tag taxonomy are all small and cache-resident,
so building the index and scanning a 20k-char body per read is cheap.
**Matching stays conservative by design.** A reference links/offers only
when it is unlikely to be coincidental:
* ``rfc_id`` tokens (e.g. ``RFC-0001``) inherently specific.
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
Single common-word titles/slugs are deliberately NOT matched they
would turn every prose occurrence into an affordance.
**Part 2 candidate heuristic.** A candidate term is a **multi-word tag**
from the #27 tag taxonomy (the de-facto set of tags the corpus already
carries) that has no defining RFC (no active or super-draft RFC whose
slug or title is that term). Multi-word is the same false-positive guard
the title rule uses: a single common tag word (``identity``) would be
far too noisy. Broader candidate detection capitalized multi-word
phrases mined from the text, terms repeated across recently-touched PRs,
or the #27 Haiku (``ANTHROPIC_API_KEY``) pathway — is a sanctioned but
deferred extension; the conservative tag-taxonomy heuristic is chosen
here to match Part 1's false-positive-averse philosophy.
"""
from __future__ import annotations
import json
import re
from typing import Any, Iterable, NamedTuple
class Term(NamedTuple):
"""One match key plus what to emit when it hits.
``key`` is the lowercase span to match (word-boundary, longest-first).
``kind`` is ``'active' | 'pending' | 'candidate'`` and selects the
emitted segment shape. ``slug``/``title`` carry the target RFC (active
+ pending); ``owner`` is the pending RFC's owner display name;
``term`` is the candidate's canonical display spelling.
"""
key: str
kind: str = "active"
slug: str = ""
title: str = ""
owner: str = ""
term: str = ""
# Lower number = higher precedence when two keys of equal length match at
# the same position. A real link beats a contribute offer beats a create
# offer.
_KIND_PRIORITY = {"active": 0, "pending": 1, "candidate": 2}
def _coerce(t: Term | tuple) -> Term:
"""Accept the legacy ``(key, slug, title)`` 3-tuple (treated as an
active term) alongside :class:`Term`, so direct unit-test callers and
older call sites keep working."""
if isinstance(t, Term):
return t
key, slug, title = t # legacy active 3-tuple
return Term(key=key, kind="active", slug=slug, title=title)
def _is_word_char(c: str) -> bool:
"""Word-boundary test. Hyphen and underscore count as word chars so a
match can't begin or end in the middle of a kebab/snake token."""
return c.isalnum() or c in ("-", "_")
def _emit(term: Term, label: str) -> dict[str, Any]:
"""The segment dict for a matched ``term``; ``label`` preserves source
casing."""
if term.kind == "pending":
return {
"type": "rfc-pending",
"slug": term.slug,
"label": label,
"title": term.title,
"owner": term.owner,
}
if term.kind == "candidate":
return {"type": "rfc-candidate", "label": label, "term": term.term}
return {"type": "rfc", "slug": term.slug, "label": label, "title": term.title}
def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]:
"""Split ``text`` into text / link segments against ``terms``.
``terms`` are :class:`Term` objects (or legacy ``(key, slug, title)``
active 3-tuples). Matching is case-insensitive, respects word
boundaries on both ends, and prefers the longest key then higher
:data:`_KIND_PRIORITY` at any position.
Always returns at least one segment; for empty/None input that is a
single empty text segment, so callers can render uniformly.
"""
ordered = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
if not text:
return [{"type": "text", "text": text or ""}]
out: list[dict[str, Any]] = []
buf: list[str] = []
low = text.lower()
n = len(text)
i = 0
while i < n:
match: tuple[Term, int] | None = None
for term in ordered:
klen = len(term.key)
if klen == 0 or not low.startswith(term.key, i):
continue
before = text[i - 1] if i > 0 else ""
after = text[i + klen] if i + klen < n else ""
if _is_word_char(before) or _is_word_char(after):
continue
match = (term, klen)
break
if match is not None:
term, klen = match
if buf:
out.append({"type": "text", "text": "".join(buf)})
buf = []
out.append(_emit(term, text[i:i + klen]))
i += klen
else:
buf.append(text[i])
i += 1
if buf:
out.append({"type": "text", "text": "".join(buf)})
return out
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
"""The match keys an RFC contributes. See the module docstring for why
each gate exists (conservative, false-positive-averse)."""
if rfc_id:
rid = rfc_id.strip()
if len(rid) >= 2:
yield rid.lower()
if title:
t = title.strip()
# Multi-word titles only — a single common word is too noisy.
if len(t) >= 2 and (" " in t or "\t" in t):
yield t.lower()
if slug:
s = slug.strip()
# Hyphenated slugs only — a single-token slug is a bare word.
if len(s) >= 2 and "-" in s:
yield s.lower()
def _slugify(term: str) -> str:
"""Deterministic kebab-case — mirrors the propose modal's slugify so a
tag's would-be slug compares correctly against existing RFC slugs."""
return re.sub(r"-+$", "", re.sub(r"^-+", "", re.sub(r"[^a-z0-9]+", "-", term.lower().strip())))
class LinkIndex:
"""A reusable term index built once per request and applied to many
bodies (a PR's description plus every comment on it)."""
def __init__(self, terms: Iterable[Term | tuple]):
# Coerce + order once; segment_text re-sorts defensively but a
# pre-sorted list keeps the per-body cost to the scan itself.
self._terms: list[Term] = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
def __bool__(self) -> bool:
return bool(self._terms)
def segment(self, text: str | None) -> list[dict[str, Any]]:
return segment_text(text, self._terms)
def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str:
"""The display name to show for a pending RFC's owner. First entry of
``owners_json`` resolved to its user row's display name, falling back
to the bare login, then ``proposed_by``, then a neutral noun."""
login = None
try:
owners = json.loads(owners_json or "[]")
if isinstance(owners, list):
login = next((o for o in owners if isinstance(o, str) and o.strip()), None)
except (ValueError, TypeError):
login = None
if login:
row = conn.execute(
"SELECT display_name FROM users WHERE gitea_login = ?", (login,)
).fetchone()
if row and row["display_name"]:
return row["display_name"]
return login
return (proposed_by or "").strip() or "the proposer"
def _tag_universe(conn) -> list[str]:
"""Distinct tags across the cached corpus (the #27 de-facto taxonomy),
preserving original spelling; case-deduped."""
rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall()
out: list[str] = []
seen: set[str] = set()
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
low = tag.lower()
if tag and low not in seen:
seen.add(low)
out.append(tag)
return out
def build_index(
conn,
*,
exclude_slug: str | None = None,
include_pending: bool = True,
include_candidates: bool = True,
) -> LinkIndex:
"""Build a :class:`LinkIndex` over the three buckets.
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
to, so an RFC's own title/id/slug don't self-link (or self-offer)
inside its own PR or discussion. Precedence is enforced by insertion
order active keys are added first and a later bucket never overrides
an already-claimed key.
"""
terms: list[Term] = []
seen: set[str] = set()
def add(key: str, term: Term) -> None:
if key in seen:
return
seen.add(key)
terms.append(term)
# --- Part 1: accepted (active) RFCs. ORDER BY slug makes key
# de-duplication deterministic when two RFCs would contribute the
# same key (first slug wins). ---
active_rows = conn.execute(
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
).fetchall()
# Track every slug + title that *has* a defining RFC, so Part 2 never
# offers to create one that already exists (active or pending).
defined_slugs: set[str] = set()
defined_titles: set[str] = set()
for r in active_rows:
slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if exclude_slug is not None and slug == exclude_slug:
continue
title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
for key in _keys_for(slug, title, rfc_id):
add(key, Term(key=key, kind="active", slug=slug, title=title))
# --- Part 3: pending (super-draft) RFCs. ---
pending_rows = conn.execute(
"""
SELECT slug, title, rfc_id, owners_json, proposed_by
FROM cached_rfcs WHERE state = 'super-draft' ORDER BY slug
"""
).fetchall()
for r in pending_rows:
slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if not include_pending:
continue
if exclude_slug is not None and slug == exclude_slug:
continue
title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
owner = _owner_display(conn, r["owners_json"], r["proposed_by"])
for key in _keys_for(slug, title, rfc_id):
add(key, Term(key=key, kind="pending", slug=slug, title=title, owner=owner))
# --- Part 2: strong-candidate terms with no defining RFC. ---
if include_candidates:
for tag in _tag_universe(conn):
low = tag.lower()
# Conservative: multi-word tags only (same guard as titles).
if " " not in tag and "\t" not in tag:
continue
if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs:
continue
add(low, Term(key=low, kind="candidate", term=tag))
return LinkIndex(terms)
+244
View File
@@ -0,0 +1,244 @@
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
A cheap, fast assist: given the partial RFC draft a user is typing, ask
Claude Haiku to recommend tags drawn ONLY from the tags already in use
across the corpus. v1 has no curated tag list tags are free-form chip
input (§9.1) so "the taxonomy" is the de-facto set of distinct tags
the existing RFCs already carry. The model is constrained to that set
and MUST NOT invent new tags; taxonomy extension (letting the model
propose genuinely new tags) is a deferred follow-up per the roadmap.
Why Haiku specifically: cost. Picking a few reasonable tags from a known
set is well within Haiku's range, and the modal fires this repeatedly as
the user types, so the per-call price has to stay small.
The whole surface degrades to silence rather than error: no Anthropic
key, no provider, an empty corpus, a rate-limited caller, an empty
draft, or an unparseable model reply all yield an empty suggestion list.
The propose modal hides its suggestion row on an empty list, so the
fallback is simply the existing free-form chip input with nothing extra
shown.
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from . import db
from .providers import BaseProvider, construct_haiku
log = logging.getLogger(__name__)
# Bound the universe handed to the model so a large corpus can't blow up
# the prompt size (and the cost). The most-common tags matter most.
_UNIVERSE_CAP = 200
# How many suggestions we ever return to the modal.
DEFAULT_MAX_SUGGESTIONS = 6
@dataclass
class Draft:
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
rationale; `use_case` is the #26 optional ground-truth field."""
title: str = ""
pitch: str = ""
use_case: str = ""
def is_empty(self) -> bool:
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
def haiku_provider(config) -> BaseProvider | None:
"""Construct a dedicated Claude Haiku provider from the operator's
Anthropic key, or return None when no key is configured.
None means "suggestions unavailable" the caller returns an empty
list and the modal shows nothing. This is the seam tests monkeypatch
to inject a fake provider without a real key. There is no RFC slug at
propose time, so the §6.7 per-RFC funder path deliberately does not
apply: tag suggestion always runs on the operator's own key.
"""
key = getattr(config, "anthropic_api_key", "") or ""
if not key:
return None
return construct_haiku(key)
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
"""Every distinct tag in use across the cached corpus, most-common
first (ties broken alphabetically for determinism), capped.
This is the universe the model is constrained to. An empty corpus
yields an empty list, which short-circuits suggestion to silence.
"""
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
counts: dict[str, int] = {}
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
if tag:
counts[tag] = counts.get(tag, 0) + 1
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
return ranked[:cap]
# ---------------------------------------------------------------------------
# Rate limiting — in-process, per-user sliding window.
#
# Cost control, not security: the `require_contributor` gate already
# bounds callers to authenticated beta users, and the modal debounces.
# This is the backstop against a stuck/abusive client hammering the
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
# and resets on restart, which is fine for a cost guard.
# ---------------------------------------------------------------------------
_CALLS: dict[int, list[float]] = {}
def _rate_config() -> tuple[int, float]:
try:
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
except ValueError:
max_calls = 30
try:
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
except ValueError:
window = 60.0
return max_calls, window
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
"""True if this call is within the per-user window; records the call.
`now` is injectable for tests (defaults to a monotonic clock)."""
max_calls, window = _rate_config()
t = time.monotonic() if now is None else now
calls = _CALLS.setdefault(user_id, [])
cutoff = t - window
calls[:] = [c for c in calls if c > cutoff]
if len(calls) >= max_calls:
return False
calls.append(t)
return True
def reset_rate_limits() -> None:
"""Test seam — clear the in-process window state."""
_CALLS.clear()
# ---------------------------------------------------------------------------
# Prompt + parse.
# ---------------------------------------------------------------------------
_SYSTEM = (
"You suggest topic tags for a draft RFC — a structured proposal "
"document in a collection. You are given the draft's title, its "
"rationale, an optional use case, and the exact set of tags already "
"in use across the collection. Choose the tags from that set that "
"best fit the draft.\n"
"Rules:\n"
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
"- Return at most {max} tags.\n"
"Respond with ONLY a JSON array, no prose, of the form:\n"
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
"If no tag in the set fits the draft, return []."
)
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
system = _SYSTEM.format(max=max_suggestions)
parts: list[str] = []
if draft.title.strip():
parts.append(f"Title: {draft.title.strip()[:300]}")
if draft.pitch.strip():
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
if draft.use_case.strip():
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
parts.append(
"Tags already in use (choose only from these):\n" + ", ".join(universe)
)
return system, [{"role": "user", "content": "\n\n".join(parts)}]
def _extract_json_array(text: str):
start = text.find("[")
end = text.rfind("]")
if start == -1 or end == -1 or end < start:
return None
try:
return json.loads(text[start : end + 1])
except ValueError:
return None
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
"""Parse the model reply into a clean, universe-constrained list.
Tolerant of the model returning bare strings or objects, extra prose
around the JSON, unknown/invented tags (dropped), duplicate tags
(deduped), and missing/garbage confidences (defaulted + clamped).
"""
data = _extract_json_array(text or "")
if not isinstance(data, list):
return []
# Map back to the canonical spelling in the universe, case-insensitively,
# so a model that lowercases a tag still resolves to the real one.
canonical = {t.lower(): t for t in universe}
out: list[dict] = []
seen: set[str] = set()
for item in data:
if isinstance(item, dict):
raw = item.get("tag")
conf = item.get("confidence")
elif isinstance(item, str):
raw, conf = item, None
else:
continue
if not isinstance(raw, str):
continue
tag = canonical.get(raw.strip().lower())
if tag is None or tag in seen:
continue
try:
c = float(conf) if conf is not None else 0.5
except (ValueError, TypeError):
c = 0.5
c = max(0.0, min(1.0, c))
out.append({"tag": tag, "confidence": round(c, 3)})
seen.add(tag)
if len(out) >= max_suggestions:
break
return out
def suggest(
provider: BaseProvider,
draft: Draft,
universe: list[str],
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
) -> list[dict]:
"""Orchestrate one suggestion call. Returns [] for an empty draft or
empty universe (no model call), and for any provider/parse failure."""
if draft.is_empty() or not universe:
return []
system, history = _build_messages(draft, universe, max_suggestions)
try:
text = provider.send(system, history)
except Exception as exc: # provider/network failure → silent empty
log.warning("tag-suggest provider failed: %s", exc)
return []
return parse_reply(text, universe, max_suggestions)
+23 -5
View File
@@ -73,6 +73,19 @@ def _siteverify_url() -> str:
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
async def _siteverify_post(url: str, data: dict) -> httpx.Response:
"""Perform the siteverify POST on an `httpx.AsyncClient`.
Isolated as a narrow seam (I4, security-audit-0026): the call is
awaited so a slow CloudFlare response can't block the event loop,
and tests patch *this function* rather than the shared
`httpx.AsyncClient` (which other modules gitea, docs also
construct, so a global patch would break app boot).
"""
async with httpx.AsyncClient(timeout=10.0) as client:
return await client.post(url, data=data)
@dataclass
class VerifyOutcome:
"""Result of a Turnstile siteverify call.
@@ -98,7 +111,7 @@ class VerifyOutcome:
reason: str
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
Returns a VerifyOutcome describing whether the calling endpoint
@@ -108,9 +121,14 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
* 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed"
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
+ a MockTransport client) to avoid touching the real CloudFlare
endpoint. No real keys are ever embedded in tests.
Async (I4, security-audit-0026): the siteverify call is awaited on an
`httpx.AsyncClient` so a slow CloudFlare response can't block the
event loop (the prior synchronous `httpx.post` stalled the single
worker for up to the 10s timeout). Callers must `await` it.
Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
touching the real CloudFlare endpoint and to keep the patch off the
shared `httpx.AsyncClient`. No real keys are ever embedded in tests.
"""
secret = _secret()
required = _required()
@@ -133,7 +151,7 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
data["remoteip"] = client_ip
try:
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
response = await _siteverify_post(_siteverify_url(), data)
payload = response.json()
except Exception as exc: # network, JSON parse, etc.
log.warning("Turnstile siteverify call failed: %s", exc)
+44 -4
View File
@@ -12,10 +12,11 @@ import hashlib
import hmac
import json
import logging
import os
from fastapi import APIRouter, Header, HTTPException, Request
from . import cache, db
from . import cache, db, projects as projects_mod, registry as registry_mod
from .config import Config
from .gitea import Gitea
@@ -40,7 +41,27 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
if config.webhook_secret:
# v0.18.0: defense in depth. config.py refuses to start
# when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1`
# is set; this branch catches the dev-bypass case (the only
# path where `config.webhook_secret` can be empty) and surfaces
# it loudly to the client. A POST that lands here with an
# empty secret on a production deployment indicates a
# mis-configuration (somebody flipped the bypass in prod),
# and the loud 500 is the proposal's whole point.
insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
if not config.webhook_secret:
if not insecure:
log.error(
"webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty "
"and RFC_APP_INSECURE_WEBHOOKS=1 is not set"
)
raise HTTPException(status_code=500, detail="Webhook receiver misconfigured")
log.warning(
"webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — "
"signature verification is DISABLED. Production deployments MUST NOT set this."
)
else:
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid signature")
@@ -58,9 +79,22 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
except Exception:
payload = {}
repo_full = (payload.get("repository") or {}).get("full_name") or ""
meta_full = f"{config.gitea_org}/{config.meta_repo}"
registry_full = f"{config.gitea_org}/{config.registry_repo}"
content_repo = projects_mod.default_content_repo(config)
if not content_repo:
log.warning("webhook: default project content_repo is unknown; corpus refresh skipped")
content_full = f"{config.gitea_org}/{content_repo}" if content_repo else None
try:
if repo_full == meta_full or not repo_full:
if repo_full == registry_full:
# §22.2: a registry-repo push re-mirrors the projects table.
# Tolerate a malformed projects.yaml (keep last-good rows); let a
# transport error bubble to the outer 500 so an unreachable Gitea
# on a registry push is loud rather than silently dropped.
try:
await registry_mod.refresh_registry(config, gitea)
except registry_mod.RegistryError:
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
elif content_full and (repo_full == content_full or not repo_full):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
@@ -68,6 +102,12 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
slug = _slug_for_repo(repo_full)
if slug:
await cache.refresh_rfc_repo(config, gitea, slug)
else:
log.info(
"webhook received for unknown repo: repo_full=%s event=%s "
"(no cached_rfcs row matched; hook may be on a fork or stale)",
repo_full, event,
)
except Exception:
log.exception("webhook refresh failed")
raise HTTPException(status_code=500, detail="Refresh failed")
@@ -0,0 +1,30 @@
-- v0.18.0 Slice 4: outbound_emails audit table.
--
-- Per the v0.18.0 email + webhook hygiene proposal §3, every send
-- helper writes a row to this table before returning, regardless
-- of outcome. status='sent' on success, 'failed' on exception,
-- 'deferred' on the dev-fallback path (no SMTP_HOST configured).
--
-- The table is queried by `GET /api/admin/outbound-emails` to
-- answer "did this person ever get their invite?" without having
-- to grep VM logs, and by the v0.18.0 Slice 5 bounce-correlation
-- hook (which looks up message_id when a POST lands at
-- /api/webhooks/email-bounce and marks the matching row
-- status='bounced').
CREATE TABLE IF NOT EXISTS outbound_emails (
id INTEGER PRIMARY KEY,
to_address TEXT NOT NULL,
from_address TEXT NOT NULL,
subject TEXT NOT NULL,
kind TEXT NOT NULL, -- 'otc' | 'invite' | 'notification' | 'bundle' | 'digest' | 'rfc-invite'
sent_at TEXT NOT NULL, -- ISO 8601, time the send was attempted
status TEXT NOT NULL, -- 'sent' | 'failed' | 'deferred' | 'bounced'
error TEXT, -- exception class + message if status='failed'
notification_id INTEGER, -- nullable FK to notifications.id for the watcher path
message_id TEXT -- the Message-ID header value, for bounce correlation
);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_to ON outbound_emails(to_address);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_sent_at ON outbound_emails(sent_at);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_message ON outbound_emails(message_id);
@@ -0,0 +1,47 @@
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
-- this for?" capture on the two propose surfaces.
--
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
-- (002_cache.sql). We add the nullable column to each, matching the
-- existing naming convention (no NOT NULL, no default — NULL is the
-- "left blank" sentinel the view surfaces render tastefully).
--
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
-- sets only the columns it knows about, so an unlisted column is
-- preserved on the update path — yet a propose/open never *writes* the
-- column through the cache (the write path is endpoint -> Gitea ->
-- reconcile, and the reconciler does not carry this field). So the cache
-- column alone would always read NULL.
--
-- The durable home is therefore a dedicated app-truth table the propose
-- /open endpoints write directly (keyed by the PR number, which is the
-- stable identity for both idea PRs and rfc_branch PRs) and the view
-- endpoints read back. This is not cache — it is canonical and survives
-- any reconcile. The cache columns are added too for parity with the
-- roadmap's literal shape and for any future reconciler that learns to
-- carry the field, but the side table is the source of truth read at
-- view time.
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
-- Canonical, reconcile-proof store. One row per propose/open that
-- supplied a use case. `scope` distinguishes the propose-RFC surface
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
-- is the join key the endpoints already have in hand. NULL/omitted use
-- cases simply never write a row here, so absence == "left blank".
CREATE TABLE proposed_use_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope, pr_number)
);
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
@@ -0,0 +1,54 @@
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
--
-- Track each authenticated user's last-viewed route + a small bag of
-- "light" component state so that the *next* sign-in can land the user
-- back where they left off, rather than always dropping them on the
-- empty-state home view.
--
-- Storage shape (one row per user — per-user, NOT per-device, per the
-- #29 "safe default"):
--
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
-- AUTOINCREMENT). A deleted user automatically loses their stored
-- resume state. One row per user means a later sign-in on any
-- device resumes the most-recently-recorded route — the per-user
-- model the roadmap asks for.
--
-- * `last_route` — the frontend pathname the user was last on
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
-- route-change POST lands. NEVER contains draft-buffer contents —
-- it is a route only. See SPEC §6.2 "Sign-in state resume
-- (privacy)".
--
-- * `last_route_state` — a JSON-encoded bag of *light* component
-- state (scroll anchors, open-tab selection, filter chips, etc.).
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
-- cf. permission_events.details, actions.details). Nullable.
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
-- PR bodies, comment drafts, or any user-typed content — only
-- ephemeral view state safe to replay. The PUT handler is the
-- enforcement point; the column comment is the contract.
--
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
-- "resume me where I left off"; 0 means "always land on home". The
-- PUT handler no-ops the upsert when this is 0, and the read path
-- refuses to hand back a stored route when this is 0. A
-- profile-settings toggle UI to flip this is a follow-up (the
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
--
-- * `last_updated_at` — TEXT timestamp, app convention
-- `datetime('now')`, matching device_trust.last_seen_at /
-- users.last_seen_at. Refreshed on every successful upsert.
--
-- No new env vars. The debounce interval for the frontend route-change
-- POST is a frontend constant (~1s), not a server knob.
CREATE TABLE user_session_state (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_route TEXT,
last_route_state TEXT, -- JSON-encoded light state, nullable
resume_enabled INTEGER NOT NULL DEFAULT 1,
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -0,0 +1,18 @@
-- v0.25.0 / security audit 0026, finding H1.
--
-- The OTC verify path had no attempt-limit or lockout, unlike the
-- passcode path (015_passcode.sql gave users.passcode_failed_attempts +
-- passcode_locked_until). This table gives the OTC verify endpoint the
-- same per-identity lockout shape. It is keyed by email rather than
-- user_id because an OTC sign-in may not have a users row yet — the row
-- is provisioned only on a *successful* verify, so the lockout state has
-- to survive independently of it.
--
-- The per-IP rate limiter (app/ratelimit.py) is the primary brute-force
-- defense; this table is the parity layer that mirrors the passcode
-- lockout and persists across restarts.
CREATE TABLE IF NOT EXISTS otc_verify_state (
email TEXT PRIMARY KEY,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
@@ -0,0 +1,59 @@
-- v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
--
-- When the #28 scanner matches a term in submitted PR/comment text to a
-- *pending* RFC (a super-draft: accepted-as-an-idea but not yet graduated
-- to an active RFC), the reader is offered a "ask to contribute" popover.
-- Submitting it lands a row here AND a notification in each owner's §15
-- inbox; the owner can accept (which fires #12's owner-invite flow with
-- the requester as the invitee) or decline (the requester is notified and
-- the request closes).
--
-- A "pending RFC" is scoped to a super-draft (cached_rfcs.state =
-- 'super-draft'): it is in cached_rfcs (so the rfc_invitations FK that the
-- accept path reuses resolves), it carries owners (owners_json) to route
-- the request to, and it already has a discussion/contribution surface to
-- open. Pre-merge idea PRs (not yet in cached_rfcs, no contribution
-- surface) are deliberately out of scope — see backend/app/rfc_links.py.
--
-- The request row is the persistent record; the inbox notification is the
-- owner-facing actionable surface keyed back to it via `notification_id`.
CREATE TABLE IF NOT EXISTS contribution_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL
REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
requester_user_id INTEGER NOT NULL
REFERENCES users(id) ON DELETE CASCADE,
-- The term in the PR/comment text that surfaced the offer (e.g. the
-- super-draft's title). Carried for the owner's context line and the
-- requester's "what RFC" anchor; not a foreign key.
matched_term TEXT NOT NULL,
-- The three contribute-request fields (§15 / #26 vocabulary).
-- `who_i_am` and `why` are required; `use_case` mirrors #26's
-- optional ground-truth field.
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- The rfc_invitations row minted on accept (the #12 reuse), and the
-- owner-facing notification row that carries the Accept/Decline action.
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_rfc
ON contribution_requests(rfc_slug, status);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_requester
ON contribution_requests(requester_user_id, status);
-- At most one open (pending) request per (RFC, requester): a second ask
-- while one is still pending is a 409, not a duplicate row. A decided
-- request (accepted/declined) does not block a fresh ask later.
CREATE UNIQUE INDEX IF NOT EXISTS idx_contribution_requests_one_open
ON contribution_requests(rfc_slug, requester_user_id)
WHERE status = 'pending';
+61
View File
@@ -0,0 +1,61 @@
-- §3 / §13.7: add the `retired` soft-delete state to cached_rfcs.
--
-- SQLite cannot ALTER a CHECK constraint in place, so we rebuild the table
-- with the expanded constraint and copy the rows across. cached_rfcs is a
-- §4 cache (reconstructible from Gitea by the reconciler), and nothing
-- holds a foreign key into it, so the rebuild is safe; we preserve the
-- existing rows anyway to avoid a needless full re-read on upgrade.
--
-- The rebuilt table must carry EVERY column cached_rfcs has accumulated,
-- including the ones added by later migrations via ALTER TABLE ADD COLUMN:
-- 009_per_rfc_models -> models_json
-- 010_funder -> funder_login
-- 021_proposed_use_case -> proposed_use_case
-- They are appended last (matching the live column order) and copied
-- across explicitly so nothing is dropped.
--
-- The migration runner wraps this file in a single BEGIN/COMMIT, so the
-- swap is atomic.
CREATE TABLE cached_rfcs_new (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT, -- 'RFC-NNNN' or NULL (NULL is also valid for an active RFC graduated without a number, §13.2)
repo TEXT, -- 'org/repo' or NULL; always NULL under the meta-only topology (§1)
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT, -- 009_per_rfc_models
funder_login TEXT, -- 010_funder
proposed_use_case TEXT -- 021_proposed_use_case
);
INSERT INTO cached_rfcs_new
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
models_json, funder_login, proposed_use_case)
SELECT
slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
models_json, funder_login, proposed_use_case
FROM cached_rfcs;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs_new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
+105
View File
@@ -0,0 +1,105 @@
-- §22 (multi-project deployments) — Slice M1: the project spine.
--
-- A deployment now hosts one or more *projects*, each a corpus with its own
-- content repo, slug/RFC-NNNN namespace, catalog, roster, and branding. The
-- pre-multi-project single-corpus deployment is the N=1 case: this migration
-- generates one 'default' project and stamps every existing RFC-scoped row
-- to it, so the app keeps running exactly as before with the spine
-- underneath (see docs/design/multi-project-spec.md §22.13).
--
-- STRATEGY — additive, no table rebuilds. Every slug-bearing table gets a
-- `project_id TEXT NOT NULL DEFAULT 'default'` column. The constant default
-- means every existing INSERT in the codebase that does not yet mention
-- project_id keeps working and lands rows in the default project; no query
-- breaks because, with a single project, slugs remain globally unique. The
-- column carries no inline REFERENCES clause: SQLite's ALTER TABLE ADD
-- COLUMN forbids a FK column with a non-NULL default. project_id referential
-- integrity is therefore enforced at the app layer for now; the FK lands
-- with the table rebuilds below.
--
-- ============================================================================
-- DEFERRED to the slice that activates project #2 (M3/M4). Until a second
-- project exists these are correct as-is; the moment two projects can share a
-- slug or a Gitea PR number they become cross-project collision bugs and MUST
-- be rebuilt (SQLite needs a create-copy-drop-rename per table) to fold
-- project_id into the key, and to add the project_id FK:
-- * cached_rfcs PRIMARY KEY (slug) -> (project_id, slug)
-- * cached_branches UNIQUE (rfc_slug, branch_name) -> +project_id
-- * branch_visibility UNIQUE (rfc_slug, branch_name) -> +project_id
-- * branch_contribute_grants UNIQUE (rfc_slug, branch_name, grantee_user_id) -> +project_id
-- * stars UNIQUE (user_id, rfc_slug) -> +project_id
-- * watches UNIQUE (user_id, rfc_slug) -> +project_id
-- * pr_seen UNIQUE (user_id, rfc_slug, pr_number) -> +project_id
-- * branch_chat_seen UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
-- * funder_consents PRIMARY KEY (user_id, rfc_slug) -> +project_id
-- * rfc_collaborators UNIQUE INDEX (rfc_slug, user_id) -> +project_id
-- * contribution_requests UNIQUE INDEX (rfc_slug, requester_user_id) WHERE pending -> +project_id
-- * proposed_use_cases UNIQUE (scope, pr_number) -> +project_id
-- (PR numbers are per-content-repo = per-project)
-- cached_prs UNIQUE (repo, pr_number) is already globally unique (repo is the
-- full 'org/repo' string, distinct per project) and needs no rebuild.
-- ============================================================================
-- The project registry cache (mirrored from the git registry by the M3
-- reconciler; rows are never written from user actions). content_repo is
-- NULL until the mirror — or the M1 startup backfill (§22.13 step 1) — sets
-- it from the deployment's configured repo.
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
content_repo TEXT,
visibility TEXT NOT NULL DEFAULT 'gated'
CHECK (visibility IN ('gated', 'public', 'unlisted')),
config_json TEXT,
registry_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- The default project. visibility='public' preserves the pre-multi-project
-- open-by-default posture (§22.5, §22.13). name is a placeholder the startup
-- backfill / registry overwrites with the deployment's display name.
INSERT OR IGNORE INTO projects (id, name, visibility)
VALUES ('default', 'default', 'public');
-- Per-(user, project) membership and the §22.6 middle-tier role. This is the
-- new tier between the §6.1 deployment role (users.role, now deployment-scope
-- only) and the §6.3 per-RFC authority.
CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'project_viewer'
CHECK (role IN ('project_admin', 'project_contributor', 'project_viewer')),
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id);
-- The project_id spine across every slug-bearing table. Backfills existing
-- rows to 'default' via the constant column default.
ALTER TABLE cached_rfcs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE cached_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE cached_prs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_visibility ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_contribute_grants ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE stars ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE threads ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE changes ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE pr_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_chat_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE watches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE notifications ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE actions ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE pr_resolution_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE funder_consents ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE rfc_invitations ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE rfc_collaborators ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE proposed_use_cases ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE contribution_requests ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
-- The catalog/directory lookup the M3 surfaces will make (RFCs in a project).
-- The per-table composite-key rebuilds in the DEFERRED block above will add
-- their own (project_id, …) indexes when they land.
CREATE INDEX IF NOT EXISTS idx_cached_rfcs_project ON cached_rfcs(project_id);
@@ -0,0 +1,31 @@
-- §22 M3 (Plan A) — additive registry/runtime-config schema.
--
-- This is the additive half of M3's backend. It adds the project `type` and
-- `initial_state` columns (mirrored from the registry), the deployment
-- singleton (deployment name/tagline mirrored from the registry), and the
-- §22.4c review columns on cached_rfcs. NO table rebuilds: the §22.13 PK
-- rebuilds and the default->slug re-stamp ride a later migration (Plan B),
-- just before a second project can collide (see migration 026's DEFERRED
-- block and docs/superpowers/specs/2026-06-03-m3-backend-design.md §1/§6).
ALTER TABLE projects ADD COLUMN type TEXT NOT NULL DEFAULT 'document'
CHECK (type IN ('document', 'specification', 'bdd'));
ALTER TABLE projects ADD COLUMN initial_state TEXT NOT NULL DEFAULT 'super-draft'
CHECK (initial_state IN ('super-draft', 'active'));
-- Deployment-level identity (name, tagline) mirrored from the registry's
-- `deployment:` block. A singleton: the CHECK pins it to one row.
CREATE TABLE IF NOT EXISTS deployment (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
tagline TEXT,
registry_sha TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO deployment (id) VALUES (1);
-- §22.4c review flag + provenance. unreviewed is git-truth (mirrored from
-- entry frontmatter); it survives a cache rebuild like `state` does.
ALTER TABLE cached_rfcs ADD COLUMN unreviewed INTEGER NOT NULL DEFAULT 0;
ALTER TABLE cached_rfcs ADD COLUMN reviewed_at TEXT;
ALTER TABLE cached_rfcs ADD COLUMN reviewed_by TEXT;
@@ -0,0 +1,276 @@
-- migrate:no-foreign-keys
--
-- §22.13 / §22.4 — fold project_id into the slug-keyed PRIMARY KEY / UNIQUE
-- constraints that migration 026 deliberately left global, so a *second*
-- project can hold an entry with the same slug as the first. M1 (026) added
-- project_id additively (no rebuild); this is the rebuild that activates
-- project #2, enumerated in 026's header.
--
-- SQLite can't ALTER a PK/UNIQUE in place, so each table is rebuilt by the
-- official procedure: create `<t>__new` with the new constraint, copy, DROP
-- the live table, rename `<t>__new` -> `<t>`, recreate its indexes. FK
-- enforcement is OFF for the whole file (the `migrate:no-foreign-keys` marker
-- above tells the runner to toggle it and run foreign_key_check after).
--
-- DROP-the-live-table (rather than rename-live-to-__old) is deliberate: SQLite
-- rewrites child FK references when you RENAME a *referenced* table, so we drop
-- the old cached_rfcs (allowed with FK off) and rename the temp in. cached_rfcs
-- is rebuilt FIRST so rfc_collaborators / contribution_requests can re-point
-- their FK at its new composite (project_id, slug) key.
-- ── cached_rfcs: PRIMARY KEY (slug) -> (project_id, slug) ──────────────────
CREATE TABLE cached_rfcs__new (
slug TEXT NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT,
repo TEXT,
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT,
funder_login TEXT,
proposed_use_case TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
unreviewed INTEGER NOT NULL DEFAULT 0,
reviewed_at TEXT,
reviewed_by TEXT,
PRIMARY KEY (project_id, slug)
);
INSERT INTO cached_rfcs__new SELECT * FROM cached_rfcs;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
CREATE INDEX idx_cached_rfcs_project ON cached_rfcs(project_id);
-- ── rfc_invitations: FK rfc_slug -> cached_rfcs(slug) becomes composite ────
-- (no key change of its own, but its single-column FK to cached_rfcs is now a
-- mismatch against the composite PK, so it must be rebuilt too). Rebuilt after
-- cached_rfcs (its FK target) and before the two tables that FK rfc_invitations.
CREATE TABLE rfc_invitations__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitee_email TEXT NOT NULL,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT,
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_invitations__new SELECT * FROM rfc_invitations;
DROP TABLE rfc_invitations;
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
-- ── cached_branches: UNIQUE (rfc_slug, branch_name) -> +project_id ─────────
CREATE TABLE cached_branches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name)
);
INSERT INTO cached_branches__new SELECT * FROM cached_branches;
DROP TABLE cached_branches;
ALTER TABLE cached_branches__new RENAME TO cached_branches;
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- ── branch_visibility: UNIQUE (rfc_slug, branch_name) -> +project_id ───────
CREATE TABLE branch_visibility__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name)
);
INSERT INTO branch_visibility__new SELECT * FROM branch_visibility;
DROP TABLE branch_visibility;
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
-- ── branch_contribute_grants: UNIQUE (rfc_slug, branch_name, grantee) -> +pid
CREATE TABLE branch_contribute_grants__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name, grantee_user_id)
);
INSERT INTO branch_contribute_grants__new SELECT * FROM branch_contribute_grants;
DROP TABLE branch_contribute_grants;
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- ── stars: UNIQUE (user_id, rfc_slug) -> +project_id ───────────────────────
CREATE TABLE stars__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug)
);
INSERT INTO stars__new SELECT * FROM stars;
DROP TABLE stars;
ALTER TABLE stars__new RENAME TO stars;
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
-- ── watches: UNIQUE (user_id, rfc_slug) -> +project_id ─────────────────────
CREATE TABLE watches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug)
);
INSERT INTO watches__new SELECT * FROM watches;
DROP TABLE watches;
ALTER TABLE watches__new RENAME TO watches;
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
-- ── pr_seen: UNIQUE (user_id, rfc_slug, pr_number) -> +project_id ──────────
CREATE TABLE pr_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug, pr_number)
);
INSERT INTO pr_seen__new SELECT * FROM pr_seen;
DROP TABLE pr_seen;
ALTER TABLE pr_seen__new RENAME TO pr_seen;
-- ── branch_chat_seen: UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
CREATE TABLE branch_chat_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug, branch_name)
);
INSERT INTO branch_chat_seen__new SELECT * FROM branch_chat_seen;
DROP TABLE branch_chat_seen;
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
-- ── funder_consents: PRIMARY KEY (user_id, rfc_slug) -> +project_id ────────
CREATE TABLE funder_consents__new (
user_id INTEGER NOT NULL,
rfc_slug TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
PRIMARY KEY (project_id, user_id, rfc_slug),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO funder_consents__new SELECT * FROM funder_consents;
DROP TABLE funder_consents;
ALTER TABLE funder_consents__new RENAME TO funder_consents;
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
-- ── rfc_collaborators: UNIQUE idx (rfc_slug, user_id) -> +project_id;
-- FK rfc_slug -> cached_rfcs(slug) becomes composite (project_id, rfc_slug)
CREATE TABLE rfc_collaborators__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_collaborators__new SELECT * FROM rfc_collaborators;
DROP TABLE rfc_collaborators;
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (project_id, rfc_slug, user_id);
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
-- ── contribution_requests: UNIQUE idx (rfc_slug, requester) WHERE pending
-- -> +project_id; FK rfc_slug -> cached_rfcs(slug) becomes composite.
CREATE TABLE contribution_requests__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
matched_term TEXT NOT NULL,
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO contribution_requests__new SELECT * FROM contribution_requests;
DROP TABLE contribution_requests;
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
CREATE UNIQUE INDEX idx_contribution_requests_one_open
ON contribution_requests(project_id, rfc_slug, requester_user_id)
WHERE status = 'pending';
-- ── proposed_use_cases: UNIQUE (scope, pr_number) -> +project_id ───────────
CREATE TABLE proposed_use_cases__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, scope, pr_number)
);
INSERT INTO proposed_use_cases__new SELECT * FROM proposed_use_cases;
DROP TABLE proposed_use_cases;
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
+474
View File
@@ -0,0 +1,474 @@
-- migrate:no-foreign-keys
--
-- §22 three-tier refactor — S1. Insert a *collection* grain beneath project.
--
-- (1) a `collections` table beneath `projects`;
-- (2) move the per-corpus fields (type, initial_state) down from `projects`
-- (projects keeps id, name, content_repo, visibility, config_json, …);
-- (3) one default collection per project (id='default' for the standard
-- single-project deployment, subfolder = repo root), inheriting the
-- project's type / initial_state / visibility;
-- (4) re-key the 13 entry-corpus tables (project_id, slug) -> (collection_id,
-- slug) via the migration-028 rebuild pattern, mapping each row to its
-- project's default collection by JOIN;
-- (5) generalise project_members -> memberships(scope_type ∈ {project,
-- collection}, scope_id, …), collapsing the role enum to {owner,
-- contributor} (§B.3).
--
-- SQLite can't ALTER a PK/UNIQUE in place, so each keyed table is rebuilt by the
-- official create-copy-drop-rename procedure. FK enforcement is OFF for the file
-- (the `migrate:no-foreign-keys` marker tells the runner to toggle it and run
-- foreign_key_check after). cached_rfcs is rebuilt FIRST so the child tables can
-- re-point their composite FK at its new (collection_id, slug) key.
--
-- The tables 026 tagged with project_id but 028 did NOT key (threads, changes,
-- notifications, actions, pr_resolution_branches, cached_prs) keep project_id —
-- they carry a project-grain tag, untouched in S1. See
-- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
-- ── §22.13 repair: re-stamp stale satellite project_id before rekeying ──────
-- The §22.13 default→ohm re-stamp (v0.39.0, `projects.restamp_default_project`)
-- updated `cached_rfcs.project_id` but NOT the entry-satellite tables, leaving
-- rows with a stale `project_id` (e.g. 'default') that the per-project collection
-- backfill below cannot map — the subquery returns NULL and the NOT NULL rebuild
-- fails (`cached_branches__new.collection_id`). Before rebuilding, re-derive each
-- satellite's `project_id` from its entry (`cached_rfcs`, joined by slug — slugs
-- are unique per collection and, pre-rebuild, globally), and drop rows whose
-- entry no longer exists (stale cache; the `cached_*` tables are rebuildable from
-- gitea). On a clean/fresh deployment every satellite is empty or already
-- consistent, so this whole block is a no-op. (Discovered on the OHM data:
-- ~1.3k `cached_branches` rows stranded at project_id='default'.)
-- First drop stale rows that DUPLICATE an already-correctly-stamped row (the same
-- branch cached under both the stale and the real project_id) — re-stamping them
-- would collide on the (project_id, rfc_slug, branch_name) key. The correctly-
-- stamped copy is kept (it carries the current head_sha / visibility). Only the
-- branch-keyed tables can hold such a pair; the others key on (rfc_slug,user_id)
-- /(scope,pr_number) and have no stale data here, so they need no dedup.
DELETE FROM cached_branches WHERE project_id NOT IN (SELECT id FROM projects)
AND EXISTS (SELECT 1 FROM cached_branches o WHERE o.rfc_slug = cached_branches.rfc_slug AND o.branch_name = cached_branches.branch_name AND o.project_id IN (SELECT id FROM projects));
DELETE FROM branch_visibility WHERE project_id NOT IN (SELECT id FROM projects)
AND EXISTS (SELECT 1 FROM branch_visibility o WHERE o.rfc_slug = branch_visibility.rfc_slug AND o.branch_name = branch_visibility.branch_name AND o.project_id IN (SELECT id FROM projects));
UPDATE rfc_invitations SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_invitations.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM rfc_invitations WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE cached_branches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = cached_branches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM cached_branches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE branch_visibility SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_visibility.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM branch_visibility WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE branch_contribute_grants SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_contribute_grants.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM branch_contribute_grants WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE stars SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = stars.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM stars WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE watches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = watches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM watches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE pr_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = pr_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM pr_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE branch_chat_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_chat_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM branch_chat_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE funder_consents SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = funder_consents.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM funder_consents WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE rfc_collaborators SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_collaborators.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM rfc_collaborators WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE contribution_requests SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = contribution_requests.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM contribution_requests WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE proposed_use_cases SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = proposed_use_cases.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM proposed_use_cases WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
-- ── collections: the new typed-corpus grain beneath projects ───────────────
CREATE TABLE collections (
id TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'document'
CHECK (type IN ('document', 'specification', 'bdd')),
subfolder TEXT NOT NULL DEFAULT '',
initial_state TEXT NOT NULL DEFAULT 'super-draft'
CHECK (initial_state IN ('super-draft', 'active')),
visibility TEXT NOT NULL DEFAULT 'gated'
CHECK (visibility IN ('gated', 'public', 'unlisted')),
name TEXT,
registry_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (id)
);
CREATE INDEX idx_collections_project ON collections(project_id);
-- One default collection per project. id='default' for the standard
-- single-project deployment (a stable literal across deploy histories); the
-- project_id is used as a unique fallback id only if a non-standard
-- multi-project deployment migrates (pre-S5; avoids a PK collision).
INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name)
SELECT
CASE WHEN (SELECT COUNT(*) FROM projects) <= 1 THEN 'default' ELSE p.id END,
p.id, p.type, '', p.initial_state, p.visibility, p.name
FROM projects p;
-- ── projects: rebuild to DROP the per-corpus fields (type, initial_state) ───
CREATE TABLE projects__new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
content_repo TEXT,
visibility TEXT NOT NULL DEFAULT 'gated'
CHECK (visibility IN ('gated', 'public', 'unlisted')),
config_json TEXT,
registry_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO projects__new (id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at)
SELECT id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at FROM projects;
DROP TABLE projects;
ALTER TABLE projects__new RENAME TO projects;
-- ── cached_rfcs: PRIMARY KEY (project_id, slug) -> (collection_id, slug) ────
CREATE TABLE cached_rfcs__new (
slug TEXT NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT,
repo TEXT,
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT,
funder_login TEXT,
proposed_use_case TEXT,
collection_id TEXT NOT NULL DEFAULT 'default' REFERENCES collections(id),
unreviewed INTEGER NOT NULL DEFAULT 0,
reviewed_at TEXT,
reviewed_by TEXT,
PRIMARY KEY (collection_id, slug)
);
INSERT INTO cached_rfcs__new
(slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at,
graduated_by, owners_json, arbiters_json, tags_json, body, body_sha,
last_main_commit_at, last_entry_commit_at, updated_at, models_json,
funder_login, proposed_use_case, collection_id, unreviewed, reviewed_at, reviewed_by)
SELECT
r.slug, r.title, r.state, r.rfc_id, r.repo, r.proposed_by, r.proposed_at, r.graduated_at,
r.graduated_by, r.owners_json, r.arbiters_json, r.tags_json, r.body, r.body_sha,
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at, r.models_json,
r.funder_login, r.proposed_use_case,
(SELECT c.id FROM collections c WHERE c.project_id = r.project_id LIMIT 1),
r.unreviewed, r.reviewed_at, r.reviewed_by
FROM cached_rfcs r;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
CREATE INDEX idx_cached_rfcs_collection ON cached_rfcs(collection_id);
-- ── rfc_invitations: single-col FK -> composite (collection_id, rfc_slug) ───
CREATE TABLE rfc_invitations__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitee_email TEXT NOT NULL,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT,
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
collection_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_invitations__new
(id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc, status, token,
expires_at, created_at, accepted_at, accepted_by_user_id, collection_id)
SELECT
i.id, i.rfc_slug, i.inviter_user_id, i.invitee_email, i.role_in_rfc, i.status, i.token,
i.expires_at, i.created_at, i.accepted_at, i.accepted_by_user_id,
(SELECT c.id FROM collections c WHERE c.project_id = i.project_id LIMIT 1)
FROM rfc_invitations i;
DROP TABLE rfc_invitations;
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
-- ── cached_branches: UNIQUE (project_id, rfc_slug, branch_name) -> collection
CREATE TABLE cached_branches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, rfc_slug, branch_name)
);
INSERT INTO cached_branches__new
(id, rfc_slug, branch_name, head_sha, state, pinned, created_at, last_commit_at, closed_at, collection_id)
SELECT
b.id, b.rfc_slug, b.branch_name, b.head_sha, b.state, b.pinned, b.created_at, b.last_commit_at, b.closed_at,
(SELECT c.id FROM collections c WHERE c.project_id = b.project_id LIMIT 1)
FROM cached_branches b;
DROP TABLE cached_branches;
ALTER TABLE cached_branches__new RENAME TO cached_branches;
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- ── branch_visibility: UNIQUE (project_id, rfc_slug, branch_name) -> collection
CREATE TABLE branch_visibility__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, rfc_slug, branch_name)
);
INSERT INTO branch_visibility__new
(id, rfc_slug, branch_name, read_public, contribute_mode, collection_id)
SELECT
v.id, v.rfc_slug, v.branch_name, v.read_public, v.contribute_mode,
(SELECT c.id FROM collections c WHERE c.project_id = v.project_id LIMIT 1)
FROM branch_visibility v;
DROP TABLE branch_visibility;
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
-- ── branch_contribute_grants: UNIQUE (..., grantee) -> +collection_id ───────
CREATE TABLE branch_contribute_grants__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, rfc_slug, branch_name, grantee_user_id)
);
INSERT INTO branch_contribute_grants__new
(id, rfc_slug, branch_name, grantee_user_id, granted_by, granted_at, collection_id)
SELECT
g.id, g.rfc_slug, g.branch_name, g.grantee_user_id, g.granted_by, g.granted_at,
(SELECT c.id FROM collections c WHERE c.project_id = g.project_id LIMIT 1)
FROM branch_contribute_grants g;
DROP TABLE branch_contribute_grants;
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- ── stars: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ──────────
CREATE TABLE stars__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug)
);
INSERT INTO stars__new (id, user_id, rfc_slug, starred_at, collection_id)
SELECT s.id, s.user_id, s.rfc_slug, s.starred_at,
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
FROM stars s;
DROP TABLE stars;
ALTER TABLE stars__new RENAME TO stars;
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
-- ── watches: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ────────
CREATE TABLE watches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT,
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug)
);
INSERT INTO watches__new
(id, user_id, rfc_slug, state, set_by, set_at, last_participation_at, collection_id)
SELECT
w.id, w.user_id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at,
(SELECT c.id FROM collections c WHERE c.project_id = w.project_id LIMIT 1)
FROM watches w;
DROP TABLE watches;
ALTER TABLE watches__new RENAME TO watches;
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
-- ── pr_seen: UNIQUE (project_id, user_id, rfc_slug, pr_number) -> collection ─
CREATE TABLE pr_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug, pr_number)
);
INSERT INTO pr_seen__new
(id, user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at, collection_id)
SELECT
p.id, p.user_id, p.rfc_slug, p.pr_number, p.last_seen_commit_sha, p.last_seen_message_id, p.seen_at,
(SELECT c.id FROM collections c WHERE c.project_id = p.project_id LIMIT 1)
FROM pr_seen p;
DROP TABLE pr_seen;
ALTER TABLE pr_seen__new RENAME TO pr_seen;
-- ── branch_chat_seen: UNIQUE (project_id, user_id, rfc_slug, branch) -> coll ─
CREATE TABLE branch_chat_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug, branch_name)
);
INSERT INTO branch_chat_seen__new
(id, user_id, rfc_slug, branch_name, last_seen_message_id, seen_at, collection_id)
SELECT
s.id, s.user_id, s.rfc_slug, s.branch_name, s.last_seen_message_id, s.seen_at,
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
FROM branch_chat_seen s;
DROP TABLE branch_chat_seen;
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
-- ── funder_consents: PRIMARY KEY (project_id, user_id, rfc_slug) -> collection
CREATE TABLE funder_consents__new (
user_id INTEGER NOT NULL,
rfc_slug TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
PRIMARY KEY (collection_id, user_id, rfc_slug),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO funder_consents__new (user_id, rfc_slug, created_at, collection_id)
SELECT f.user_id, f.rfc_slug, f.created_at,
(SELECT c.id FROM collections c WHERE c.project_id = f.project_id LIMIT 1)
FROM funder_consents f;
DROP TABLE funder_consents;
ALTER TABLE funder_consents__new RENAME TO funder_consents;
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
-- ── rfc_collaborators: UNIQUE idx + composite FK -> collection_id ───────────
CREATE TABLE rfc_collaborators__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_collaborators__new
(id, rfc_slug, user_id, role_in_rfc, invitation_id, created_at, collection_id)
SELECT
rc.id, rc.rfc_slug, rc.user_id, rc.role_in_rfc, rc.invitation_id, rc.created_at,
(SELECT c.id FROM collections c WHERE c.project_id = rc.project_id LIMIT 1)
FROM rfc_collaborators rc;
DROP TABLE rfc_collaborators;
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (collection_id, rfc_slug, user_id);
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
-- ── contribution_requests: UNIQUE idx (pending) + composite FK -> collection ─
CREATE TABLE contribution_requests__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
matched_term TEXT NOT NULL,
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
collection_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
);
INSERT INTO contribution_requests__new
(id, rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case, status,
created_at, decided_at, decided_by_user_id, invitation_id, notification_id, collection_id)
SELECT
cr.id, cr.rfc_slug, cr.requester_user_id, cr.matched_term, cr.who_i_am, cr.why, cr.use_case, cr.status,
cr.created_at, cr.decided_at, cr.decided_by_user_id, cr.invitation_id, cr.notification_id,
(SELECT c.id FROM collections c WHERE c.project_id = cr.project_id LIMIT 1)
FROM contribution_requests cr;
DROP TABLE contribution_requests;
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
CREATE UNIQUE INDEX idx_contribution_requests_one_open
ON contribution_requests(collection_id, rfc_slug, requester_user_id)
WHERE status = 'pending';
-- ── proposed_use_cases: UNIQUE (project_id, scope, pr_number) -> collection ──
CREATE TABLE proposed_use_cases__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, scope, pr_number)
);
INSERT INTO proposed_use_cases__new
(id, scope, rfc_slug, pr_number, use_case, created_at, collection_id)
SELECT
u.id, u.scope, u.rfc_slug, u.pr_number, u.use_case, u.created_at,
(SELECT c.id FROM collections c WHERE c.project_id = u.project_id LIMIT 1)
FROM proposed_use_cases u;
DROP TABLE proposed_use_cases;
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
-- ── project_members -> memberships(scope_type, scope_id, …); roles collapsed ─
CREATE TABLE memberships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')),
scope_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope_type, scope_id, user_id)
);
CREATE INDEX idx_memberships_user ON memberships(user_id);
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
-- M2 project_members rows attached at what is now the *collection*; collapse the
-- role enum (project_admin -> owner, project_contributor -> contributor;
-- project_viewer dropped this pass, §B.3) and migrate onto the default
-- collection of each project.
INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by, granted_at)
SELECT 'collection',
(SELECT c.id FROM collections c WHERE c.project_id = pm.project_id LIMIT 1),
pm.user_id,
CASE pm.role WHEN 'project_admin' THEN 'owner'
WHEN 'project_contributor' THEN 'contributor'
ELSE 'contributor' END,
pm.granted_by, pm.granted_at
FROM project_members pm
WHERE pm.role IN ('project_admin', 'project_contributor');
DROP TABLE project_members;
+34
View File
@@ -0,0 +1,34 @@
-- migrate:no-foreign-keys
--
-- §22 three-tier — S3. Admit a *global*-scope grant to the memberships table.
--
-- §B.2's resolver folds four layers (global → project → collection → per-entry).
-- Migration 029 created `memberships` with scope_type ∈ {project, collection}
-- only; the global tier was left to S3. A global grant is how a "global RFC
-- Contributor" (a contributor who may propose in every collection of every
-- project, distinct from a deployment owner/admin) is represented — see
-- docs/design/2026-06-05-three-tier-projects-collections.md §B.2/§B.3 and the
-- C.1 "cleo" scenario.
--
-- SQLite can't ALTER a CHECK constraint in place, so the table is rebuilt by the
-- create-copy-drop-rename procedure (the 028/029 pattern). The global scope uses
-- a stable sentinel scope_id of '*' (one global tier per deployment); the
-- UNIQUE(scope_type, scope_id, user_id) then admits exactly one global grant per
-- user, mirroring the project/collection rows.
CREATE TABLE memberships__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'project', 'collection')),
scope_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope_type, scope_id, user_id)
);
INSERT INTO memberships__new (id, scope_type, scope_id, user_id, role, granted_by, granted_at)
SELECT id, scope_type, scope_id, user_id, role, granted_by, granted_at FROM memberships;
DROP TABLE memberships;
ALTER TABLE memberships__new RENAME TO memberships;
CREATE INDEX idx_memberships_user ON memberships(user_id);
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
@@ -0,0 +1,9 @@
-- §22.12 S6 — per-collection model universe.
--
-- A collection's `.collection.yaml` may carry an `enabled_models` list that
-- NARROWS its project's universe (which narrows the deployment ENABLED_MODELS).
-- Mirrored into a `config_json` blob on the collection row, paralleling
-- `projects.config_json` (which already holds the project's enabled_models +
-- theme). Additive only — no rebuild. NULL means "no per-collection narrowing;
-- inherit the project's universe."
ALTER TABLE collections ADD COLUMN config_json TEXT;
+58
View File
@@ -0,0 +1,58 @@
-- §22.8 S6 — request-to-join a scope + the cross-collection inbox.
--
-- A gated project or collection is invisible to non-members (§22.5), so a user
-- who knows a scope exists can ask to join it: they name a desired role and the
-- request is recorded here, then fanned out to that scope's Owners *across the
-- subtree* (a collection request reaches the collection's Owners, its project's
-- Owners, and global Owners — the cross-collection inbox, §22.11). An Owner
-- accepts (which writes the `memberships` row via memberships.grant) or declines;
-- the requester is §15-notified of the decision either way.
--
-- This mirrors `contribution_requests` (migration 024) but at the scope grain
-- instead of the per-RFC grain: the target is a `(scope_type, scope_id)` pair
-- (matching the `memberships` scope vocabulary, minus 'global' — joining is for a
-- project or collection a user discovers, not the deployment), and accept grants
-- a scope role rather than minting an RFC invitation.
--
-- The request row is the persistent record; the inbox notification is the
-- owner-facing actionable surface keyed back to it via `notification_id`.
CREATE TABLE IF NOT EXISTS join_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- The target scope. 'global' is intentionally excluded: the deployment is
-- not a thing one "discovers and joins" (§22.8 names a project/collection).
scope_type TEXT NOT NULL
CHECK (scope_type IN ('project', 'collection')),
scope_id TEXT NOT NULL,
requester_user_id INTEGER NOT NULL
REFERENCES users(id) ON DELETE CASCADE,
-- The role the requester is asking for ({owner, contributor}, the §22.6
-- unified vocabulary). The accepting Owner may grant this or a narrower role.
requested_role TEXT NOT NULL
CHECK (requested_role IN ('owner', 'contributor')),
-- Optional free text — "who I am / why I want in". Bounded by the API layer.
message TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- The role actually granted on accept (may differ from requested_role if the
-- Owner narrowed it); NULL until accepted.
granted_role TEXT CHECK (granted_role IN ('owner', 'contributor')),
-- The owner-facing notification row that carries the Accept/Decline action.
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_join_requests_scope
ON join_requests(scope_type, scope_id, status);
CREATE INDEX IF NOT EXISTS idx_join_requests_requester
ON join_requests(requester_user_id, status);
-- At most one open (pending) request per (scope, requester): a second ask while
-- one is still pending is a 409, not a duplicate row. A decided request
-- (accepted/declined) does not block a fresh ask later.
CREATE UNIQUE INDEX IF NOT EXISTS idx_join_requests_one_open
ON join_requests(scope_type, scope_id, requester_user_id)
WHERE status = 'pending';
@@ -0,0 +1,8 @@
-- §22.4a SLICE-1 — configurable collection metadata: malformed-sidecar flag.
--
-- The corpus mirror reads an entry's metadata from its `<slug>.meta.yaml`
-- sidecar (dual-read: sidecar-else-legacy-frontmatter). A sidecar that does not
-- parse as a YAML mapping never hard-fails the read (INV-3) — the entry still
-- loads (from the legacy `.md` frontmatter if present) and this derived flag
-- marks it so the catalog can surface it. Additive only — no rebuild. 0 = ok.
ALTER TABLE cached_rfcs ADD COLUMN metadata_malformed INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,12 @@
-- §22.4a SLICE-3 — configurable collection metadata: cache per-entry values.
--
-- Faceted filtering (§5.1) needs each entry's metadata values (priority, custom
-- enum/tags fields) to compute facet counts and honour filter params. Today the
-- mirror keeps only `tags_json` + the lifecycle columns and drops `Entry.extra`,
-- so a declared field's values are unrecoverable. This column persists the full
-- per-entry metadata mapping (`metadata.metadata_dict(entry)`, known keys +
-- extra, never the body) as JSON, so `app/facets.py` can read any declared
-- field uniformly. Additive + nullable — no rebuild. NULL = not yet re-ingested
-- (the reconciler/webhook fills it on the next sweep) → that entry contributes
-- no facet values until then. SLICE-4/5 edit panels read the same column.
ALTER TABLE cached_rfcs ADD COLUMN meta_json TEXT;
+19
View File
@@ -0,0 +1,19 @@
"""Shared pytest fixtures for the backend suite.
Added in v0.27.0 (security audit 0026) alongside the new per-IP rate
limiter. The limiters in `app.ratelimit` are process-global singletons,
so their state survives across tests within a run; without a reset, the
accumulated requests from earlier tests exhaust the budget and later
tests see spurious 429s. This autouse fixture gives every test a clean
limiter window.
"""
import pytest
from app import ratelimit
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
ratelimit._reset_all_for_tests()
yield
ratelimit._reset_all_for_tests()
@@ -647,3 +647,82 @@ def test_pending_invites_listing_admin_only(app_with_fake_gitea):
)
r = client.get("/api/admin/users/invites")
assert r.status_code == 403
# ---------------------------------------------------------------------------
# v0.18.0: invite-envelope header shape — Slice 2
#
# Invite mail goes through `build_envelope` and MUST land Date,
# Message-ID, Auto-Submitted, AND a `List-Unsubscribe: <mailto:…>`
# (no URL — the invitee isn't a user yet, so no per-user opt-out
# row exists). The mailto: target is the operator's `EMAIL_FROM`
# by default; the operator can override via `EMAIL_UNSUBSCRIBE_MAILTO`.
# ---------------------------------------------------------------------------
def _provision_admin_and_send_invite(client, app_with_fake_gitea_fixture, *, to: str = "headers@ex.co"):
provision_user_row(user_id=400, login="adminH", role="admin")
sign_in_as(
client, user_id=400, gitea_login="adminH",
display_name="Admin H", role="admin",
email="adminh@test",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": to,
"first_name": "Header",
"last_name": "Test",
"role": "contributor",
"custom_message": "",
},
)
assert r.status_code == 200, r.text
return _outbound_invite_envelopes(to)[-1]
def test_invite_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
assert parsedate_to_datetime(msg["Date"]) is not None
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
assert msg["Auto-Submitted"] == "auto-generated"
def test_invite_envelope_has_mailto_list_unsubscribe_only(app_with_fake_gitea):
"""The invitee isn't a user yet — no per-user opt-out URL is
available. The `List-Unsubscribe` MUST be a mailto: form, and
the `List-Unsubscribe-Post` header MUST be absent (the
one-click semantic requires a URL the MUA can POST to)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
lu = msg["List-Unsubscribe"]
assert lu is not None and lu.startswith("<mailto:")
# No URL part — invite is mailto-only.
assert "https://" not in lu and "http://" not in lu
assert msg["List-Unsubscribe-Post"] is None
def test_invite_envelope_respects_email_unsubscribe_mailto_override(app_with_fake_gitea, monkeypatch):
"""When `EMAIL_UNSUBSCRIBE_MAILTO` is set, the mailto: target on
`List-Unsubscribe` honors it (lets a deployment route opt-outs
to a humans-monitored mailbox distinct from the no-reply
sender)."""
from fastapi.testclient import TestClient
monkeypatch.setenv("EMAIL_UNSUBSCRIBE_MAILTO", "ohm@wiggleverse.org?subject=remove")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
assert "ohm@wiggleverse.org?subject=remove" in msg["List-Unsubscribe"]
+139
View File
@@ -0,0 +1,139 @@
"""§22.9/§22.5 — GET /api/deployment + GET /api/projects/:id with visibility."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _add_project(pid, name, vis, typ="document"):
# §22 three-tier: a project (grouping tier) + its default collection (the
# per-corpus type/initial_state moved down in migration 029). The default
# collection keys by the project id so it is globally unique in tests.
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
(pid, name, pid, vis),
)
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES (?, ?, ?, '', 'super-draft', ?, ?)",
(pid, pid, typ, vis, name),
)
def test_deployment_lists_public_omits_gated_and_unlisted_for_anon(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("pub", "Public", "public")
_add_project("gat", "Gated", "gated")
_add_project("unl", "Unlisted", "unlisted")
r = client.get("/api/deployment")
assert r.status_code == 200
body = r.json()
assert body["name"] == "Test Deployment"
ids = {p["id"] for p in body["projects"]}
assert "pub" in ids and "default" in ids # both public
assert "gat" not in ids # gated, anon not a member
assert "unl" not in ids # unlisted never enumerated
def test_projects_id_404_for_gated_non_member(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("gat", "Gated", "gated")
assert client.get("/api/projects/gat").status_code == 404
def test_projects_id_returns_config_for_public(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
from app import db
db.conn().execute(
"UPDATE projects SET config_json = ? WHERE id = 'default'",
('{"theme": {"accent": "#5b5bd6"}}',),
)
r = client.get("/api/projects/default")
assert r.status_code == 200
body = r.json()
assert body["id"] == "default"
assert body["type"] == "document"
assert body["visibility"] == "public"
assert body["theme"] == {"accent": "#5b5bd6"}
def test_projects_id_unlisted_readable_by_direct_id(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("unl", "Unlisted", "unlisted")
assert client.get("/api/projects/unl").status_code == 200
def test_projects_id_unknown_returns_404_even_for_superuser(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert client.get("/api/projects/does-not-exist").status_code == 404
def test_projects_id_unknown_returns_404_for_anon(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
assert client.get("/api/projects/nope").status_code == 404
def test_deployment_includes_default_project_id(app_with_fake_gitea):
# §22.10 / M3-frontend guard contract: the frontend learns which project
# is the corpus-served (default) one from the deployment payload.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
body = client.get("/api/deployment").json()
assert body["default_project_id"] == "default"
def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
# §22.10 / §5: old corpus-root /rfc/<slug> → 308 /p/<default>/e/<slug>.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/c/default/e/human"
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
# The old per-RFC PR deep link is preserved too.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human/pr/7", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/c/default/e/human/pr/7"
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/proposals/42", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/c/default/proposals/42"
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("teamx", "Team X", "gated")
provision_user_row(user_id=5, login="mia", role="contributor")
db.conn().execute(
"INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('collection', 'teamx', 5, 'contributor')"
)
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
# member sees the gated project in the deployment directory
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
assert "teamx" in ids
# member can read it directly
r = client.get("/api/projects/teamx")
assert r.status_code == 200
assert r.json()["id"] == "teamx"
+62
View File
@@ -0,0 +1,62 @@
"""SLICE-4 — bot multi-file commit + PR primitives."""
from __future__ import annotations
import asyncio
from app import gitea as gitea_mod, metadata
from app.bot import Actor, Bot
from app.config import load_config
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
provision_user_row,
tmp_env,
)
LEGACY = "---\nslug: alpha\ntitle: Alpha\nstate: active\ntags:\n- one\n---\n\nBody.\n"
def _actor():
return Actor(user_id=1, gitea_login="ben.stull", display_name="Ben", email="ben@x.io")
def test_commit_entry_files_direct_to_main(app_with_fake_gitea):
_app, fake = app_with_fake_gitea
gitea = gitea_mod.Gitea(load_config())
bot = Bot(gitea)
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
"content": LEGACY, "sha": "s1"}
st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md"))
e2 = metadata.apply_values(st.entry, {"tags": ["two"]})
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
asyncio.run(bot.commit_entry_files(
_actor(), org="wiggleverse", repo="meta", files=ops,
message="Edit metadata", branch="main"))
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
assert "two" in sc
# .md is now body-only
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
def test_open_entry_pr_commits_on_branch_and_opens_pr(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
gitea = gitea_mod.Gitea(load_config())
bot = Bot(gitea)
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
"content": LEGACY, "sha": "s1"}
with TestClient(app): # lifespan inits the DB
provision_user_row(user_id=1, login="ben.stull", role="owner")
st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md"))
e2 = metadata.apply_values(st.entry, {"tags": ["two"]})
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
pr = asyncio.run(bot.open_entry_pr(
_actor(), org="wiggleverse", repo="meta", slug="alpha", files=ops,
pr_title="Metadata: Alpha", pr_description="edit", branch_prefix="metadata"))
assert pr["number"] >= 1
head = pr["head"]["ref"]
assert head.startswith("metadata-alpha-")
# committed on the branch, main untouched
assert ("wiggleverse", "meta", head, "rfcs/alpha.meta.yaml") in fake.files
assert ("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml") not in fake.files
+24
View File
@@ -0,0 +1,24 @@
"""§22.4c — _upsert_cached_rfc mirrors the review fields into cached_rfcs."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
def test_upsert_writes_review_fields(app_with_fake_gitea):
from app import cache, db, entry as entry_mod
app, _ = app_with_fake_gitea
with TestClient(app):
e = entry_mod.Entry(
slug="rev", title="Rev", state="active",
unreviewed=True, reviewed_at="2026-06-03", reviewed_by="ben",
)
cache._upsert_cached_rfc(e, body_sha="sha-rev")
row = db.conn().execute(
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug = 'rev'"
).fetchone()
assert row["unreviewed"] == 1
assert row["reviewed_at"] == "2026-06-03"
assert row["reviewed_by"] == "ben"
@@ -0,0 +1,83 @@
"""§22 S2 — create-collection vertical: a deployment owner/admin POSTs, the bot
commits a `.collection.yaml`, and the registry mirror upserts the collections
row (registry stays the source of truth)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def test_create_collection_commits_manifest_and_mirrors(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects/default/collections",
json={"collection_id": "features", "type": "bdd", "name": "Features"})
assert r.status_code == 200, r.text
assert r.json()["type"] == "bdd"
# The bot committed the manifest to the content repo's main.
f = fake.files.get(("wiggleverse", "meta", "main", "features/.collection.yaml"))
assert f is not None
assert "type: bdd" in f["content"]
# The registry refresh mirrored it into a collections row.
row = db.conn().execute(
"SELECT type, project_id, subfolder FROM collections WHERE id='features'"
).fetchone()
assert (row["type"], row["project_id"], row["subfolder"]) == ("bdd", "default", "features")
# It is now navigable via the directory + scoped serve.
items = client.get("/api/projects/default/collections").json()["items"]
assert any(c["id"] == "features" for c in items)
assert client.get("/api/projects/default/collections/features/rfcs").status_code == 200
def test_create_collection_requires_admin(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post("/api/projects/default/collections",
json={"collection_id": "x", "type": "bdd"})
assert r.status_code in (401, 403)
def test_create_collection_anonymous_rejected(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/projects/default/collections",
json={"collection_id": "x", "type": "bdd"})
assert r.status_code in (401, 403)
def test_create_collection_rejects_duplicate(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
ok = client.post("/api/projects/default/collections",
json={"collection_id": "features", "type": "bdd"})
assert ok.status_code == 200, ok.text
dup = client.post("/api/projects/default/collections",
json={"collection_id": "features", "type": "bdd"})
assert dup.status_code == 409
def test_create_collection_rejects_reserved_default_id(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects/default/collections",
json={"collection_id": "default", "type": "bdd"})
assert r.status_code == 422
+85
View File
@@ -0,0 +1,85 @@
"""§22 S2 — collection read helpers: list_collections / get_collection /
subfolder_of."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from app import collections as collections_mod, db
from app.config import Config
def _db() -> Config:
cfg = Config(
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
registry_repo="registry", oauth_client_id="x",
oauth_client_secret="x", app_url="x", secret_key="x",
database_path=Path(tempfile.mkdtemp(prefix="colhelp-")) / "t.db",
owner_gitea_login="x", webhook_secret="x",
)
db.run_migrations(cfg)
if db._CONN is not None:
db._CONN.close()
db._CONN = None
db.init(cfg)
return cfg
def _seed(project_id="ohm"):
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES (?, 'Ohm', 'ohm-rfc', 'public', datetime('now'))", (project_id,))
for cid, sub, vis, name in [
("default", "", "public", "Model"),
("features", "features", "public", "Features"),
("secret", "secret", "unlisted", "Secret"),
]:
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
"visibility, name, created_at, updated_at) VALUES (?,?, 'document', ?, "
"'super-draft', ?, ?, datetime('now'), datetime('now'))",
(cid, project_id, sub, vis, name))
def test_list_collections_excludes_unlisted():
_db()
_seed()
ids = [c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=False)]
assert ids == ["default", "features"] # default first, then by name; 'secret' omitted
def test_list_collections_include_unlisted():
_db()
_seed()
ids = {c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=True)}
assert ids == {"default", "features", "secret"}
def test_get_collection_and_subfolder():
_db()
_seed()
assert collections_mod.get_collection("features")["name"] == "Features"
assert collections_mod.subfolder_of("features") == "features"
assert collections_mod.subfolder_of("default") == ""
assert collections_mod.get_collection("nope") is None
# ---- §22.4a SLICE-2: field schema served on the collection ----
def test_get_collection_fields_none_when_unset():
# INV-5: a collection with no `fields:` exposes fields=None (the default
# `document` collection sees zero change).
_db()
_seed()
assert collections_mod.get_collection("features")["fields"] is None
def test_get_collection_exposes_field_schema():
_db()
_seed()
schema = {"priority": {"type": "enum", "values": ["P0", "P1"]}}
db.conn().execute(
"UPDATE collections SET config_json = ? WHERE id = 'features'",
(json.dumps({"fields": schema}),))
assert collections_mod.get_collection("features")["fields"] == schema
+176
View File
@@ -0,0 +1,176 @@
"""§22 S2 — the registry mirror reads `.collection.yaml` manifests inside each
project's content repo and upserts a named collection per manifest. The default
collection still flows from projects.yaml (test_registry.py)."""
from __future__ import annotations
import asyncio
import base64
import tempfile
from pathlib import Path
import pytest
from app import db, registry
from app.config import Config
def _db() -> Config:
cfg = Config(
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
registry_repo="registry", oauth_client_id="x",
oauth_client_secret="x", app_url="x", secret_key="x",
database_path=Path(tempfile.mkdtemp(prefix="colreg-")) / "t.db",
owner_gitea_login="x", webhook_secret="x",
)
db.run_migrations(cfg)
if db._CONN is not None:
db._CONN.close()
db._CONN = None
db.init(cfg)
return cfg
# --- pure parser --------------------------------------------------------------
def test_parse_collection_manifest_minimal():
doc = registry.parse_collection_manifest("type: bdd\n")
assert doc.type == "bdd"
# §22.4b: bdd defaults to 'active'; visibility inherits (None == inherit).
assert doc.initial_state == "active"
assert doc.visibility is None
assert doc.name is None
def test_parse_collection_manifest_full():
doc = registry.parse_collection_manifest(
"type: document\nvisibility: public\ninitial_state: active\nname: Model\n"
)
assert (doc.type, doc.visibility, doc.initial_state, doc.name) == (
"document", "public", "active", "Model",
)
def test_parse_collection_manifest_rejects_bad_type():
with pytest.raises(registry.RegistryError):
registry.parse_collection_manifest("type: nonsense\n")
def test_parse_collection_manifest_rejects_bad_visibility():
with pytest.raises(registry.RegistryError):
registry.parse_collection_manifest("type: bdd\nvisibility: nope\n")
# ---- §22.4a SLICE-2: a `fields:` block flows into the collection config ----
def test_parse_collection_manifest_reads_field_schema():
doc = registry.parse_collection_manifest(
"type: bdd\n"
"fields:\n"
" priority:\n"
" type: enum\n"
" values: [P0, P1, P2]\n"
" tags:\n"
" type: tags\n"
)
assert doc.config["fields"] == {
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
"tags": {"type": "tags"},
}
def test_parse_collection_manifest_lenient_on_bad_field():
# A bad field def is skipped (INV-3), the manifest still parses, and a
# manifest with no surviving fields carries no `fields` config key at all.
doc = registry.parse_collection_manifest(
"type: bdd\n"
"fields:\n"
" broken:\n"
" type: ref\n"
)
assert "fields" not in doc.config
# --- mirror discovery ---------------------------------------------------------
class _FakeGitea:
"""Minimal Gitea stub: projects.yaml in the registry repo + a content repo
whose root holds a `features/` subdir carrying a `.collection.yaml`."""
def __init__(self, projects_yaml: str, repo_tree: dict[str, dict[str, str]]):
self._projects_yaml = projects_yaml
self._repo_tree = repo_tree # {repo: {path: text}}
async def get_contents(self, org, repo, path, ref="main"):
if path == "projects.yaml":
return {"type": "file",
"content": base64.b64encode(self._projects_yaml.encode()).decode(),
"sha": "regsha-test"}
text = self._repo_tree.get(repo, {}).get(path)
if text is None:
return None
return {"type": "file",
"content": base64.b64encode(text.encode()).decode(), "sha": "c0ffee"}
async def list_dir(self, org, repo, path, ref="main"):
# Root listing: surface each top-level segment as a 'dir' entry.
prefix = (path.rstrip("/") + "/") if path else ""
dirs = set()
for p in self._repo_tree.get(repo, {}):
if not p.startswith(prefix):
continue
rest = p[len(prefix):]
if "/" in rest:
dirs.add(rest.split("/", 1)[0])
return [{"type": "dir", "name": n, "path": prefix + n} for n in sorted(dirs)]
_PROJECTS = (
"deployment:\n name: Ohm\n tagline: t\n"
"projects:\n - id: ohm\n name: Ohm\n type: document\n"
" content_repo: ohm-rfc\n visibility: public\n"
)
def test_refresh_registry_mirrors_named_collection():
cfg = _db()
gitea = _FakeGitea(
projects_yaml=_PROJECTS,
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\nname: Features\n"}},
)
asyncio.run(registry.refresh_registry(cfg, gitea))
row = db.conn().execute(
"SELECT type, subfolder, name, project_id, visibility FROM collections WHERE id='features'"
).fetchone()
assert row is not None
assert (row["type"], row["subfolder"], row["project_id"]) == ("bdd", "features", "ohm")
assert row["name"] == "Features"
# visibility inherits the project's (public) when the manifest omits it.
assert row["visibility"] == "public"
def test_refresh_registry_leaves_default_collection_intact():
cfg = _db()
gitea = _FakeGitea(
projects_yaml=_PROJECTS,
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
)
asyncio.run(registry.refresh_registry(cfg, gitea))
# The default collection (from projects.yaml) and the named one coexist.
ids = {r["id"] for r in db.conn().execute("SELECT id FROM collections")}
assert {"default", "features"} <= ids
def test_refresh_registry_immutable_type_on_named_collection():
cfg = _db()
gitea = _FakeGitea(
projects_yaml=_PROJECTS,
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
)
asyncio.run(registry.refresh_registry(cfg, gitea))
# A later manifest that flips the type is refused (§22.4a immutable type).
gitea._repo_tree["ohm-rfc"]["features/.collection.yaml"] = "type: document\n"
asyncio.run(registry.refresh_registry(cfg, gitea))
t = db.conn().execute("SELECT type FROM collections WHERE id='features'").fetchone()["type"]
assert t == "bdd"
@@ -0,0 +1,164 @@
"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose.
The mirror test drives cache.refresh_meta_repo against an in-memory content repo
holding entries under both the default `rfcs/` and a named collection's
`features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id."""
from __future__ import annotations
import asyncio
import tempfile
from pathlib import Path
from app import cache, db
from app.config import Config
def _db() -> Config:
cfg = Config(
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
registry_repo="registry", oauth_client_id="x",
oauth_client_secret="x", app_url="x", secret_key="x",
database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db",
owner_gitea_login="x", webhook_secret="x",
)
db.run_migrations(cfg)
if db._CONN is not None:
db._CONN.close()
db._CONN = None
db.init(cfg)
return cfg
class _CorpusGitea:
"""A content repo modelled as a flat {path: text} map, listing files under a
directory prefix and reading them back."""
def __init__(self, tree: dict[str, str]):
self._tree = tree
async def list_dir(self, org, repo, path, ref="main"):
out = []
prefix = (path.rstrip("/") + "/") if path else ""
for p in self._tree:
if p.startswith(prefix) and "/" not in p[len(prefix):]:
out.append({"type": "file", "name": p.split("/")[-1], "path": p})
return out
async def read_file(self, org, repo, path, ref="main"):
t = self._tree.get(path)
return (t, "sha-" + path) if t is not None else None
def _entry_md(slug, title):
return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n"
def _seed_project_with_two_collections():
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))")
for cid, sub in [("default", ""), ("features", "features")]:
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
"visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, "
"'super-draft','public', datetime('now'), datetime('now'))", (cid, sub))
def test_mirror_keys_entries_by_collection():
cfg = _db()
_seed_project_with_two_collections()
gitea = _CorpusGitea({
"rfcs/a.md": _entry_md("a", "Default A"),
"features/rfcs/b.md": _entry_md("b", "Feature B"),
})
asyncio.run(cache.refresh_meta_repo(cfg, gitea))
got = {(r["collection_id"], r["slug"]) for r in
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
assert got == {("default", "a"), ("features", "b")}
# --- collection-scoped serve + propose (full app) -----------------------------
from fastapi.testclient import TestClient # noqa: E402
from test_propose_vertical import ( # noqa: E402,F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _add_features_collection(content_repo="meta"):
"""Add a named 'features' collection (subfolder 'features') under the seeded
default project, plus a single entry under features/rfcs/ in the db cache."""
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
"initial_state, visibility, name, created_at, updated_at) VALUES "
"('features','default','document','features','super-draft','public','Features', "
"datetime('now'), datetime('now'))")
def test_scoped_list_returns_only_that_collection(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
# Seed one entry under each collection's rfcs dir + mirror them in.
fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = {
"content": _entry_md("a", "Default A"), "sha": "sa"}
fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = {
"content": _entry_md("b", "Feature B"), "sha": "sb"}
from app import cache as cache_mod, gitea as gitea_mod
from app.config import load_config
cfg = load_config()
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
r = client.get("/api/projects/default/collections/features/rfcs")
assert r.status_code == 200, r.text
assert [i["slug"] for i in r.json()["items"]] == ["b"]
# The default collection still serves only its own entry.
r2 = client.get("/api/projects/default/collections/default/rfcs")
assert [i["slug"] for i in r2.json()["items"]] == ["a"]
def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection()
provision_user_row(user_id=3, login="alice", role="contributor")
# §22 S3: an explicitly-created collection requires an explicit scope
# grant to write (the grandfathered baseline covers only `default`).
db.conn().execute(
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('collection', 'features', 3, 'contributor')")
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post(
"/api/projects/default/collections/features/rfcs/propose",
json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []})
assert r.status_code == 200, r.text
# The bot wrote the entry under features/rfcs/, not rfcs/.
keys = {(k[1], k[3]) for k in fake.files
if k[1] == "meta" and k[3].endswith("newb.md")}
assert ("meta", "features/rfcs/newb.md") in keys
def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/projects/default/collections/nope/rfcs")
assert r.status_code == 404
def test_s2_anonymous_empty_public_collection(app_with_fake_gitea):
"""C3.6 (@S2): a public collection with no entries; an anonymous visitor
lands on its catalog an empty catalog (200, no items), and the propose
action is not available to them (the propose route rejects anonymous)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_features_collection() # public, no entries
# Anonymous (no session cookie) reads the empty catalog — 200, [].
r = client.get("/api/projects/default/collections/features/rfcs")
assert r.status_code == 200, r.text
assert r.json()["items"] == []
# No propose action for an anonymous visitor.
r2 = client.post(
"/api/projects/default/collections/features/rfcs/propose",
json={"title": "X", "slug": "x", "pitch": "p", "tags": []})
assert r2.status_code == 401
@@ -0,0 +1,236 @@
"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to-
pending requests.
Two layers, mirroring test_rfc_links_vertical.py:
* The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate`
(Part 2) segments alongside Part 1's `rfc` links.
* The contribute-request flow end-to-end: a non-owner asks, each owner
gets an actionable §15 notification, accept fires #12's invite flow,
decline notifies the requester.
Reuses the FakeGitea + seed/session helpers from the existing suites.
"""
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
from test_super_draft_vertical import seed_super_draft
from test_rfc_links_vertical import _open_pr_on
def _set_owner(slug: str, login: str) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?",
(json.dumps([login]), slug),
)
def _set_tags(slug: str, tags: list[str]) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?",
(json.dumps(tags), slug),
)
def _segs(segments, kind):
return [s for s in segments if s["type"] == kind]
# ---------------------------------------------------------------------------
# Part 2 + Part 3 — scanner surfaces on the PR view
# ---------------------------------------------------------------------------
def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# Host active RFC (OHM — single word, contributes no keys itself)
# carrying a multi-word tag with no defining RFC: the Part 2
# candidate. And a pending super-draft owned by alice: the Part 3
# contribute target.
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_set_tags("ohm", ["memory model", "identity"])
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework for representing humans.", proposed_by="alice")
_set_owner("open-human-model", "alice")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="ohm",
description="This builds on the Open Human Model and the memory model.",
)
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
segs = pr["description_segments"]
pending = _segs(segs, "rfc-pending")
assert len(pending) == 1
assert pending[0]["slug"] == "open-human-model"
assert pending[0]["label"] == "Open Human Model"
assert pending[0]["owner"] == "Alice" # display_name of the owner
candidate = _segs(segs, "rfc-candidate")
assert len(candidate) == 1
assert candidate[0]["term"] == "memory model"
# "identity" is a single-word tag — deliberately NOT a candidate.
assert all("identity" not in s.get("term", "") for s in candidate)
# ---------------------------------------------------------------------------
# Part 3 — the contribute-request flow
# ---------------------------------------------------------------------------
def _seed_pending_owned_by_alice(fake):
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework.", proposed_by="alice")
_set_owner("open-human-model", "alice")
_REQUEST = {
"matched_term": "Open Human Model",
"who_i_am": "Bob, a researcher",
"why": "I have relevant prior work to bring.",
"use_case": "Building an identity tool.",
}
def test_request_accept_invites_and_notifies(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Bob asks to contribute.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 200, r.text
request_id = r.json()["id"]
assert r.json()["status"] == "pending"
# A second ask while pending is a 409, not a duplicate row.
assert client.post("/api/rfcs/open-human-model/contribution-requests",
json=_REQUEST).status_code == 409
# Alice (owner) sees the actionable notification with full detail.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
inbox = client.get("/api/notifications").json()
reqs = [i for i in inbox["items"]
if i["event_kind"] == "contribution_request_on_pending_rfc"]
assert len(reqs) == 1
assert "wants to contribute" in reqs[0]["summary"]
assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher"
assert reqs[0]["extras"]["request_id"] == request_id
# Alice accepts → #12 invitation minted for bob's email.
acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept")
assert acc.status_code == 200, acc.text
assert acc.json()["status"] == "accepted"
assert acc.json()["invitation_id"]
inv = db.conn().execute(
"SELECT invitee_email, role_in_rfc, status FROM rfc_invitations "
"WHERE rfc_slug = 'open-human-model'"
).fetchone()
assert inv["invitee_email"] == "bob@test"
assert inv["role_in_rfc"] == "contributor"
assert inv["status"] == "pending"
# The request is settled — re-accepting is a 409.
assert client.post(
f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept"
).status_code == 409
# Bob gets the accepted echo in his inbox.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_accepted" in bob_kinds
def test_decline_notifies_requester(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
request_id = client.post(
"/api/rfcs/open-human-model/contribution-requests", json=_REQUEST
).json()["id"]
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline")
assert dec.status_code == 200, dec.text
assert dec.json()["status"] == "declined"
row = db.conn().execute(
"SELECT status FROM contribution_requests WHERE id = ?", (request_id,)
).fetchone()
assert row["status"] == "declined"
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_declined" in bob_kinds
def test_owner_cannot_request_own_rfc(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 409
assert "own" in r.json()["detail"].lower()
def test_request_on_active_rfc_rejected(app_with_fake_gitea):
# The contribute offer only exists for pending super-drafts; an active
# RFC uses the Part-1 link instead.
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST)
assert r.status_code == 409
def test_contribution_target_eligibility(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Anonymous: not eligible, told to sign in.
t = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert t["eligible"] is False
assert "sign in" in (t["reason"] or "").lower()
assert t["owner"] == "Alice"
# Bob: eligible until he has a pending ask, then not.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True
client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
after = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert after["already_requested"] is True
assert after["eligible"] is False
@@ -0,0 +1,202 @@
"""§22 S5 — create-project vertical: a global Owner POSTs `/api/projects`, the
bot provisions a Gitea content repo + commits the project to `projects.yaml`, and
the registry mirror upserts the `projects` + default `collections` rows (registry
stays the source of truth). Plus the deployment-directory empty-state signals
(`viewer.can_create_project`, `default_project_readable`) that drive C3.1/C3.2.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def test_create_project_provisions_repo_commits_registry_and_mirrors(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "acme", "name": "Acme", "type": "bdd",
"visibility": "public"})
assert r.status_code == 200, r.text
body = r.json()
assert body["id"] == "acme"
assert body["type"] == "bdd"
assert body["visibility"] == "public"
# The bot provisioned the content repo (default name <id>-content) and
# seeded a README so `main` exists.
assert ("wiggleverse", "acme-content") in fake.repos
readme = fake.files.get(("wiggleverse", "acme-content", "main", "README.md"))
assert readme is not None and "acme" in readme["content"]
# The bot committed the new project into projects.yaml.
reg = fake.files.get(("wiggleverse", "registry", "main", "projects.yaml"))
assert reg is not None
assert "id: acme" in reg["content"]
assert "content_repo: acme-content" in reg["content"]
# The registry refresh mirrored a projects row + its default collection.
prow = db.conn().execute(
"SELECT name, content_repo, visibility FROM projects WHERE id='acme'"
).fetchone()
assert (prow["name"], prow["content_repo"], prow["visibility"]) == (
"Acme", "acme-content", "public")
crow = db.conn().execute(
"SELECT type, project_id, subfolder FROM collections WHERE id='acme'"
).fetchone()
assert (crow["type"], crow["project_id"], crow["subfolder"]) == ("bdd", "acme", "")
# It is now visible in the deployment directory and readable.
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
assert "acme" in ids
assert client.get("/api/projects/acme").status_code == 200
# An audit row records the structural action with the global Owner actor.
act = db.conn().execute(
"SELECT actor_user_id FROM actions WHERE action_kind='create_project'"
).fetchone()
assert act is not None and act["actor_user_id"] == 1
def test_create_project_custom_content_repo_name(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "beta", "name": "Beta", "type": "document",
"content_repo": "beta-corpus"})
assert r.status_code == 200, r.text
assert ("wiggleverse", "beta-corpus") in fake.repos
prow = db.conn().execute(
"SELECT content_repo FROM projects WHERE id='beta'"
).fetchone()
assert prow["content_repo"] == "beta-corpus"
def test_create_project_requires_global_owner(app_with_fake_gitea):
# A plain deployment contributor is not a global Owner (C: + New project is a
# global-Owner action), even though they may create collections.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post("/api/projects",
json={"project_id": "x", "name": "X", "type": "bdd"})
assert r.status_code == 403
def test_create_project_global_owner_grant_permitted(app_with_fake_gitea):
# An explicit global-scope Owner grant (not a deployment owner/admin) may
# create projects.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="gina", role="contributor")
db.conn().execute(
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('global', '*', 3, 'owner')"
)
sign_in_as(client, user_id=3, gitea_login="gina", display_name="Gina",
role="contributor", email="gina@test")
r = client.post("/api/projects",
json={"project_id": "gproj", "name": "G", "type": "document"})
assert r.status_code == 200, r.text
def test_create_project_anonymous_rejected(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/projects",
json={"project_id": "x", "name": "X", "type": "bdd"})
assert r.status_code in (401, 403)
def test_create_project_rejects_duplicate(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
ok = client.post("/api/projects",
json={"project_id": "acme", "name": "Acme", "type": "bdd"})
assert ok.status_code == 200, ok.text
dup = client.post("/api/projects",
json={"project_id": "acme", "name": "Acme 2", "type": "bdd"})
assert dup.status_code == 409
def test_create_project_rejects_reserved_default_id(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "default", "name": "X", "type": "bdd"})
assert r.status_code == 422
def test_create_project_rejects_bad_type(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
role="owner", email="ben@test")
r = client.post("/api/projects",
json={"project_id": "x", "name": "X", "type": "nonsense"})
assert r.status_code == 422
# --- C3.1 / C3.2: deployment-directory empty-state signals ------------------
def test_deployment_owner_sees_create_project_capability(app_with_fake_gitea):
# C3.1: a global Owner is offered the create-project action.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
body = client.get("/api/deployment").json()
assert body["viewer"]["can_create_project"] is True
def test_deployment_non_owner_no_create_capability(app_with_fake_gitea):
# C3.2: a granted account with no roles is not offered create-project.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="vee", role="contributor")
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
body = client.get("/api/deployment").json()
assert body["viewer"]["can_create_project"] is False
def test_deployment_default_readable_when_default_is_public(app_with_fake_gitea):
# The seeded default project is public → readable → the N=1 redirect target
# is valid (land-in-corpus preserved).
app, _ = app_with_fake_gitea
with TestClient(app) as client:
body = client.get("/api/deployment").json()
assert body["default_project_readable"] is True
def test_deployment_default_not_readable_when_only_gated(app_with_fake_gitea):
# C3.2: the only project is gated; a granted non-member sees no visible
# projects AND default_project_readable False → the frontend renders the
# empty directory (no 404 bounce).
app, _ = app_with_fake_gitea
with TestClient(app) as client:
db.conn().execute("UPDATE projects SET visibility='gated' WHERE id='default'")
db.conn().execute("UPDATE collections SET visibility='gated' WHERE id='default'")
provision_user_row(user_id=2, login="vee", role="contributor")
sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor")
body = client.get("/api/deployment").json()
assert body["projects"] == []
assert body["default_project_readable"] is False
@@ -0,0 +1,384 @@
"""v0.19.0 / roadmap item #30 — `/api/docs/sessions/*` endpoints.
The framework mediates reads against the public
`wiggleverse/ohm-session-history` gitea repo so the rendered
`/docs/sessions/*` surface inherits the same chrome as `/docs/user-guide`.
This test suite covers the four endpoints + the in-process TTL cache,
mocking the upstream HTTP via `httpx.MockTransport` (the same shape the
rest of the test suite uses for Gitea).
The tests do NOT spin up the full FakeGitea they only need to mock
the gitea raw URL surface (and the contents API for the session-index
endpoint). Each test owns its mock transport so we can dial in 200 /
404 / 5xx / timeout responses per case.
Path-validation tests intentionally bypass the network a malformed
`nnnn` or `filename` MUST be rejected at the route layer before any
upstream call is made.
"""
from __future__ import annotations
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from app import docs_sessions
# Reuse the proven app-construction fixtures from the proposal vertical
# (same shape every test file in this repo uses).
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
class _UpstreamHandler:
"""Records every URL the docs_sessions module fetched and returns
canned responses keyed by URL substring. Allows the test to assert
on call count (for cache verification) without needing a full Gitea
simulator.
`calls` tracks only URLs that hit the session-history host (the
`OHM_SESSION_HISTORY_*` bases) so reconciler/Gitea-side calls which
also pass through this handler because `httpx.AsyncClient` is a
shared attribute the gitea-side fixture also monkeypatches don't
inflate the count we use for cache-hit assertions.
"""
_SESSION_HOST_MARKERS = ("ohm-session-history", "wiggleverse/ohm-session-history")
def __init__(self, responses: dict[str, tuple[int, str]]):
self.responses = responses
self.calls: list[str] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
if any(m in url for m in self._SESSION_HOST_MARKERS):
self.calls.append(url)
for key, (status, body) in self.responses.items():
if key in url:
return httpx.Response(status, text=body)
# Default: 404. Lets tests skip declaring "the rest is 404".
return httpx.Response(404, text="not found")
@pytest.fixture
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
"""Provide a hook the test can call to install a MockTransport.
Returns a closure: `install(handler)` patches `httpx.AsyncClient`
(via `app.docs_sessions.httpx.AsyncClient`) so every constructed
client uses the handler's transport.
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
via the gitea transport. Since `httpx` is a module singleton, installing
the docs transport would clobber the FakeGitea mock already installed
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
other requests go to the test-specific handler.
"""
from httpx._client import AsyncClient as RealAsyncClient
_fake = app_with_fake_gitea[1]
def install(handler):
def composite(request: httpx.Request) -> httpx.Response:
if "gitea.test" in str(request.url):
return _fake.handle(request)
return handler(request)
def patched(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(composite)
return RealAsyncClient(*args, **kwargs)
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
return handler
yield install
@pytest.fixture
def app(app_with_fake_gitea):
"""Wrap the shared app fixture, resetting the docs-sessions cache so
cross-test state can't leak. Returns just the FastAPI app — the
fake-Gitea handle is irrelevant for the docs-sessions surface.
"""
docs_sessions.reset_cache()
fastapi_app, _fake = app_with_fake_gitea
return fastapi_app
# ---------------------------------------------------------------------------
# Manifest endpoint
# ---------------------------------------------------------------------------
def test_manifest_happy_path(app, patched_httpx):
manifest_body = json.dumps(
{
"0001": {"title": "Bootstrap"},
"0014": {"title": "Wave 7 driver"},
}
)
patched_httpx(
_UpstreamHandler({"sessions.json": (200, manifest_body)})
)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 200, r.text
payload = r.json()
assert payload == {
"0001": {"title": "Bootstrap"},
"0014": {"title": "Wave 7 driver"},
}
def test_manifest_empty_state(app, patched_httpx):
"""A 404 from gitea means the manifest hasn't been published yet.
The endpoint returns HTTP 200 + `{}` so the frontend can render the
no-sessions-yet state without an error banner.
"""
patched_httpx(_UpstreamHandler({"sessions.json": (404, "not found")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 200, r.text
assert r.json() == {}
def test_manifest_upstream_5xx_returns_502(app, patched_httpx):
patched_httpx(_UpstreamHandler({"sessions.json": (500, "internal")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 502, r.text
body = r.json()
assert body["detail"]["error"] == "session-history fetch failed"
# ---------------------------------------------------------------------------
# About endpoint
# ---------------------------------------------------------------------------
def test_about_happy_path(app, patched_httpx):
readme = "# OHM session history\n\nWelcome.\n"
patched_httpx(_UpstreamHandler({"README.md": (200, readme)}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == readme
def test_about_404(app, patched_httpx):
patched_httpx(_UpstreamHandler({"README.md": (404, "")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 404, r.text
def test_about_upstream_5xx_returns_502(app, patched_httpx):
patched_httpx(_UpstreamHandler({"README.md": (503, "down")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 502, r.text
# ---------------------------------------------------------------------------
# Transcript endpoint
# ---------------------------------------------------------------------------
def test_transcript_happy_path(app, patched_httpx):
body = "# Session 0017.1 — Transcript\n\nbody.\n"
fname = "SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md"
patched_httpx(_UpstreamHandler({fname: (200, body)}))
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/0017/{fname}")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == body
def test_transcript_404(app, patched_httpx):
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/9999/{fname}")
assert r.status_code == 404, r.text
def test_transcript_rejects_invalid_session_dir(app, patched_httpx):
"""`nnnn` must be exactly 4 digits. `abcd` fails before any
network call.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get(
"/api/docs/sessions/abcd/"
"SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
)
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_rejects_path_traversal(app, patched_httpx):
"""A filename that doesn't match the SESSION-NNNN.M-TRANSCRIPT regex
is rejected. `../etc/passwd` doesn't match; neither does the legacy
flat-root `SESSION-A-TRANSCRIPT.md`.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
# Path traversal — but FastAPI normalizes `..` in the path before
# routing, so this resolves to /api/docs/sessions/0001/etc/passwd
# which routes to the same handler with filename=etc/passwd, and
# gets rejected as an invalid transcript filename. Even if the
# normalization didn't apply (some intermediary), the regex
# check rejects anything not matching the SESSION- prefix.
r = client.get(
"/api/docs/sessions/0001/etc%2Fpasswd"
)
# 400 (filename validation) or 404 (path didn't match the
# route); both reject before any network call. Either is
# acceptable — what matters is that we never fetched it.
assert r.status_code in (400, 404), r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_rejects_legacy_flat_filename(app, patched_httpx):
"""Legacy `SESSION-A-TRANSCRIPT.md` (letter form) doesn't match the
numeric regex by design, since post-#23 transcripts live in
`NNNN/` folders with numeric names. Reject 400.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/0001/SESSION-A-TRANSCRIPT.md")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_upstream_5xx_returns_502(app, patched_httpx):
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
patched_httpx(_UpstreamHandler({fname: (502, "bad gateway")}))
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/0001/{fname}")
assert r.status_code == 502, r.text
# ---------------------------------------------------------------------------
# Session-index endpoint
# ---------------------------------------------------------------------------
def test_session_index_happy_path(app, patched_httpx):
"""The contents API returns a JSON list of file entries. The
endpoint filters to entries that match the transcript regex and
sorts them.
"""
# Two transcripts (driver + subagent) + a non-transcript sibling
# that must be filtered out.
listing = json.dumps(
[
{
"name": "SESSION-0017.0-TRANSCRIPT-"
"2026-05-28T08-30--2026-05-28T12-00.md",
"type": "file",
},
{
"name": "SESSION-0017.1-TRANSCRIPT-"
"2026-05-28T08-50--2026-05-28T11-20.md",
"type": "file",
},
{"name": "notes.md", "type": "file"}, # not a transcript
{"name": "attached-dir", "type": "dir"}, # not a file
]
)
patched_httpx(_UpstreamHandler({"/contents/0017": (200, listing)}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/0017/index")
assert r.status_code == 200, r.text
files = r.json()["files"]
assert files == [
"SESSION-0017.0-TRANSCRIPT-2026-05-28T08-30--2026-05-28T12-00.md",
"SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md",
]
def test_session_index_404(app, patched_httpx):
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get("/api/docs/sessions/9999/index")
assert r.status_code == 404, r.text
def test_session_index_rejects_invalid_session_dir(app, patched_httpx):
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/abc/index")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
# ---------------------------------------------------------------------------
# Cache behavior
# ---------------------------------------------------------------------------
def test_manifest_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
"""Two consecutive manifest calls within the TTL window should
issue exactly one HTTP request to gitea.
"""
# Generous TTL so the test never races.
monkeypatch.setenv("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", "60")
handler = _UpstreamHandler(
{"sessions.json": (200, json.dumps({"0001": {"title": "x"}}))}
)
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get("/api/docs/sessions/manifest")
r2 = client.get("/api/docs/sessions/manifest")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1, (
f"expected one upstream call, got {handler.calls}"
)
def test_transcript_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
handler = _UpstreamHandler({fname: (200, "# body\n")})
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get(f"/api/docs/sessions/0001/{fname}")
r2 = client.get(f"/api/docs/sessions/0001/{fname}")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1
def test_transcript_404_is_cached(app, patched_httpx, monkeypatch):
"""Negative caching: a 404 result is cached at the content TTL so a
deployment with no published transcripts doesn't hammer gitea on
every navigation. Documented in `docs_sessions.fetch_transcript`.
"""
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
handler = _UpstreamHandler({}) # everything 404s
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get(f"/api/docs/sessions/9999/{fname}")
r2 = client.get(f"/api/docs/sessions/9999/{fname}")
assert r1.status_code == 404
assert r2.status_code == 404
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
+478
View File
@@ -0,0 +1,478 @@
"""v0.20.0 — `/api/docs/specs/*` endpoints.
Sibling of `test_docs_sessions_vertical.py`. The framework mediates
reads of the configured framework-spec URLs (default: rfc-app's own
SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
`/docs/specs/*` surface inherits the same chrome as
`/docs/user-guide` and `/docs/sessions/*`.
This file covers:
- The manifest endpoint with the framework default
- The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value
- Slug validation at the route layer (rejects `..`, `/`, `~`,
uppercase, whitespace, path traversal attempts)
- Gitea 200 / 404 / 5xx response mapping
- Negative caching (404 is cached, not re-fetched within TTL)
- Malformed `OHM_DOCS_SPECS` fallback to the default + a logged
warning (asserted by caplog)
- A manifest entry that fails per-entry validation (bad slug,
missing field) is dropped, with the rest of the list retained
Mocking approach: same as docs_sessions `httpx.MockTransport`
substituted into `app.docs_specs.httpx.AsyncClient` via a fixture.
"""
from __future__ import annotations
import json
import logging
import httpx
import pytest
from fastapi.testclient import TestClient
from app import docs_specs
# Reuse the proven app-construction fixtures from the proposal vertical
# (same shape every test file in this repo uses).
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
class _UpstreamHandler:
"""Records every URL the docs_specs module fetched and returns
canned responses keyed by URL substring. Lets the test assert on
call count (for cache verification) without booting a full upstream
simulator.
`calls` tracks only URLs that hit a host configured in the spec
manifest under test so unrelated httpx clients (gitea-side
fixtures, etc.) don't inflate the count we use for cache-hit
assertions. We marker-match on substrings the manifest carries.
"""
def __init__(
self,
responses: dict[str, tuple[int, str]],
host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"),
):
self.responses = responses
self.host_markers = host_markers
self.calls: list[str] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
if any(m in url for m in self.host_markers):
self.calls.append(url)
for key, (status, body) in self.responses.items():
if key in url:
return httpx.Response(status, text=body)
# Default: 404. Lets tests skip declaring "the rest is 404".
return httpx.Response(404, text="not found")
@pytest.fixture
def patched_httpx(monkeypatch, app_with_fake_gitea): # noqa: F811
"""Provide a hook the test can call to install a MockTransport.
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
via the gitea transport. Since `httpx` is a module singleton, installing
the docs transport would clobber the FakeGitea mock already installed
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
other requests go to the test-specific handler.
"""
from httpx._client import AsyncClient as RealAsyncClient
_fake = app_with_fake_gitea[1]
def install(handler):
def composite(request: httpx.Request) -> httpx.Response:
if "gitea.test" in str(request.url):
return _fake.handle(request)
return handler(request)
def patched(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(composite)
return RealAsyncClient(*args, **kwargs)
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
return handler
yield install
@pytest.fixture
def app(app_with_fake_gitea):
"""Reset the docs-specs cache so cross-test state can't leak."""
docs_specs.reset_cache()
fastapi_app, _fake = app_with_fake_gitea
return fastapi_app
# ---------------------------------------------------------------------------
# Manifest endpoint
# ---------------------------------------------------------------------------
def test_manifest_default(app, monkeypatch):
"""With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the
framework default (rfc-app + flotilla).
"""
monkeypatch.delenv("OHM_DOCS_SPECS", raising=False)
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
payload = r.json()
assert "specs" in payload
names = [s["name"] for s in payload["specs"]]
assert names == ["rfc-app", "flotilla"]
# The default URLs point at the OHM-canonical gitea raw paths.
assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"])
def test_manifest_overridden(app, monkeypatch):
"""A deployment overriding `OHM_DOCS_SPECS` gets its custom list.
The manifest is parsed per-request from the env var (no startup
binding) so a runtime overlay change is visible without a
restart same shape as the docs_sessions env knobs.
"""
custom = json.dumps(
[
{
"name": "custom-spec",
"title": "Custom Spec",
"url": "https://specs.example.org/CUSTOM.md",
}
]
)
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
payload = r.json()
assert payload == {
"specs": [
{
"name": "custom-spec",
"title": "Custom Spec",
"url": "https://specs.example.org/CUSTOM.md",
}
]
}
def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog):
"""A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the
endpoint falls back to the framework default. Startup is
unaffected the deployment continues to render the spec surface
rather than crashing on the typo.
"""
monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json")
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
payload = r.json()
names = [s["name"] for s in payload["specs"]]
assert names == ["rfc-app", "flotilla"]
assert any(
"OHM_DOCS_SPECS is not valid JSON" in rec.message
for rec in caplog.records
), f"expected a logged warning; got {[r.message for r in caplog.records]}"
def test_manifest_non_list_falls_back(app, monkeypatch, caplog):
"""`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any
non-list value) falls back to the default + logs a warning.
"""
monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"}))
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
names = [s["name"] for s in r.json()["specs"]]
assert names == ["rfc-app", "flotilla"]
assert any(
"must be a JSON array" in rec.message for rec in caplog.records
)
def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog):
"""Per-entry validation: an entry with a bad slug or missing field
is dropped; valid entries in the same list are retained.
"""
custom = json.dumps(
[
{"name": "Bad Slug", "title": "Bad", "url": "https://x"}, # uppercase + space
{"name": "..", "title": "Traversal", "url": "https://x"}, # path traversal
{"name": "missing-url", "title": "Missing URL"}, # no url
{
"name": "good-spec",
"title": "Good",
"url": "https://specs.example.org/GOOD.md",
},
]
)
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
names = [s["name"] for s in r.json()["specs"]]
assert names == ["good-spec"]
# Three drop warnings (one per bad entry).
drops = [r for r in caplog.records if "failed validation" in r.message]
assert len(drops) == 3
def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog):
"""If every entry is dropped, the framework default applies (the
surface never goes empty due to a bad overlay).
"""
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]),
)
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
names = [s["name"] for s in r.json()["specs"]]
assert names == ["rfc-app", "flotilla"]
assert any(
"yielded no valid entries" in rec.message for rec in caplog.records
)
def test_manifest_drops_duplicate_names(app, monkeypatch, caplog):
"""A duplicate `name` is dropped (the first occurrence wins). The
route layer's `/api/docs/specs/{name}` path lookup is by name, so
duplicates would otherwise be ambiguous.
"""
custom = json.dumps(
[
{"name": "x", "title": "First", "url": "https://specs.example/1"},
{"name": "x", "title": "Second", "url": "https://specs.example/2"},
]
)
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
payload = r.json()
assert [s["title"] for s in payload["specs"]] == ["First"]
assert any("duplicate name" in rec.message for rec in caplog.records)
# ---------------------------------------------------------------------------
# Spec endpoint — happy + error paths
# ---------------------------------------------------------------------------
def test_spec_happy_path(app, patched_httpx, monkeypatch):
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "rfc-app",
"title": "rfc-app SPEC",
"url": "https://specs.example.org/rfc-app/SPEC.md",
}
]
),
)
body = "# rfc-app SPEC\n\nSection 1...\n"
patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)}))
with TestClient(app) as client:
r = client.get("/api/docs/specs/rfc-app")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == body
def test_spec_upstream_404(app, patched_httpx, monkeypatch):
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "missing-spec",
"title": "Missing",
"url": "https://specs.example.org/missing.md",
}
]
),
)
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get("/api/docs/specs/missing-spec")
assert r.status_code == 404, r.text
def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch):
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "broken-spec",
"title": "Broken",
"url": "https://specs.example.org/broken.md",
}
]
),
)
patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")}))
with TestClient(app) as client:
r = client.get("/api/docs/specs/broken-spec")
assert r.status_code == 502, r.text
body = r.json()
assert body["detail"]["error"] == "specs fetch failed"
def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch):
"""A name that doesn't appear in the manifest returns 404 without
touching the network. The handler treats "no such configured spec"
and "upstream 404" as the same outcome both render the same
"spec not found" empty state on the frontend.
"""
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "rfc-app",
"title": "rfc-app",
"url": "https://specs.example.org/x.md",
}
]
),
)
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/specs/does-not-exist")
assert r.status_code == 404, r.text
assert handler.calls == [], "unknown-name lookup must not hit the network"
# ---------------------------------------------------------------------------
# Spec endpoint — slug validation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw_name",
[
"UPPER", # uppercase
"spaces here", # whitespace (post-decoding)
"with~tilde", # tilde
"with.dot", # dot
"with_under", # underscore (not allowed by [a-z0-9-]+)
],
)
def test_spec_rejects_invalid_name(app, patched_httpx, raw_name):
"""Names that don't match `^[a-z0-9-]+$` are rejected with 400 at
the route layer before any network or cache work.
Note: `..` is intentionally not in this list because the URL-
parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs`
before the handler is reached the path-traversal protection is
therefore framework-level (httpx/urllib's path normalizer) rather
than route-layer. The slug-validation guard still rejects any
`..` that *would* reach the handler (e.g. via an env-configured
manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid`
for that path.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
from urllib.parse import quote
r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected name must not hit the network"
def test_spec_rejects_slash_in_name(app, patched_httpx):
"""A literal `/` in the path can't make it through the path
parameter FastAPI routes it as a separate segment. The check
here is that the request never reaches an upstream fetch.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
# `/api/docs/specs/sub/path` — the second segment makes this
# not match the `/{name}` route at all; FastAPI returns 404.
r = client.get("/api/docs/specs/sub/path")
assert r.status_code == 404, r.text
assert handler.calls == [], "non-matching path must not hit the network"
# ---------------------------------------------------------------------------
# Cache behavior
# ---------------------------------------------------------------------------
def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "rfc-app",
"title": "rfc-app",
"url": "https://specs.example.org/rfc-app/SPEC.md",
}
]
),
)
handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")})
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get("/api/docs/specs/rfc-app")
r2 = client.get("/api/docs/specs/rfc-app")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1, (
f"expected one upstream call, got {handler.calls}"
)
def test_spec_404_is_cached(app, patched_httpx, monkeypatch):
"""Negative caching: a 404 result is cached at the content TTL so
a deployment with a misconfigured spec URL doesn't hammer the
upstream on every navigation.
"""
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "missing-spec",
"title": "Missing",
"url": "https://specs.example.org/missing.md",
}
]
),
)
handler = _UpstreamHandler({}) # everything 404s
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get("/api/docs/specs/missing-spec")
r2 = client.get("/api/docs/specs/missing-spec")
assert r1.status_code == 404
assert r2.status_code == 404
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
+13 -8
View File
@@ -119,19 +119,20 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. ---
# --- 7. Graduate the super-draft (in-place flip, §13). ---
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active"
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
# in its meta entry, `repo` stays null.
assert d["repo"] is None
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
# --- 8. Alice opens a PR on the now-active RFC (meta repo). ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and
@@ -200,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
)
assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "meta")]
cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,),
@@ -231,13 +232,17 @@ def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gi
# With the right header, the call passes the guard. (No matching
# user exists, so we get {matched: False} — that's the v1 contract.)
# v0.18.0 Slice 5: the response now includes `correlated_id`
# (the outbound_emails row id that matched the bounce's
# `message_id`, if one was supplied). The body didn't pass a
# message_id, so correlated_id is None.
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "stranger@example.com", "kind": "hard"},
headers={"X-Webhook-Secret": "shhh"},
)
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "matched": False}
assert r.json() == {"ok": True, "matched": False, "correlated_id": None}
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
@@ -0,0 +1,165 @@
"""End-to-end integration tests for the deployed-environment E2E
test-auth path (`POST /auth/test/login`).
This endpoint is a **deliberately gated auth shortcut** for running the
Playwright E2E suite against a *deployed* environment (PPE) that has no
Mailpit OTC sink and no direct SQLite access to inject an owner row
the two scaffolds the Tier-1 stack relies on. It mints an authenticated
**owner** session for a single, pre-configured test identity, but ONLY
when the deployment has explicitly opted in by setting BOTH
`E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL`. It is fail-closed:
* Off by default with neither (or only one) env var set, the route
is invisible (404), so a production deployment that never sets them
cannot be coaxed into minting a session.
* Even when enabled, it requires the caller to present the shared
secret in the `X-Test-Auth-Secret` header (constant-time compare),
and it will only mint a session for the one configured email any
other address is refused (403). So the blast radius of an enabled
PPE is a single throwaway owner identity, and the secret is the
trust boundary.
The tests below pin every branch of that gate.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
tmp_env,
)
SECRET = "ppe-e2e-shared-secret-value"
EMAIL = "e2e-owner@example.test"
def test_test_login_is_404_when_disabled(app_with_fake_gitea):
"""Neither env var set (the default, incl. production) → the route
does not exist."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/auth/test/login",
json={"email": EMAIL},
headers={"X-Test-Auth-Secret": SECRET},
)
assert r.status_code == 404, r.text
def test_test_login_is_404_when_only_email_is_set(app_with_fake_gitea, monkeypatch):
"""Half-configured (email but no secret) must NOT open the route —
a framework auth shortcut gated only by a known email would be far
too weak."""
from fastapi.testclient import TestClient
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
monkeypatch.delenv("E2E_TEST_AUTH_SECRET", raising=False)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/auth/test/login",
json={"email": EMAIL},
headers={"X-Test-Auth-Secret": SECRET},
)
assert r.status_code == 404, r.text
def test_test_login_refuses_wrong_secret(app_with_fake_gitea, monkeypatch):
"""Enabled, but a bad/absent secret → 404 (don't advertise the
route's existence to an unauthenticated caller)."""
from fastapi.testclient import TestClient
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Wrong secret.
r = client.post(
"/auth/test/login",
json={"email": EMAIL},
headers={"X-Test-Auth-Secret": "not-the-secret"},
)
assert r.status_code == 404, r.text
# Absent secret.
r = client.post("/auth/test/login", json={"email": EMAIL})
assert r.status_code == 404, r.text
def test_test_login_refuses_unconfigured_email(app_with_fake_gitea, monkeypatch):
"""Right secret but an email other than the single configured
identity 403. Even a secret-bearer can only mint the one test
owner."""
from fastapi.testclient import TestClient
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/auth/test/login",
json={"email": "someone-else@example.test"},
headers={"X-Test-Auth-Secret": SECRET},
)
assert r.status_code == 403, r.text
def test_test_login_mints_owner_session(app_with_fake_gitea, monkeypatch):
"""The happy path: right secret + configured email → an authenticated
session whose user is a GRANTED OWNER (so the metadata write paths
SLICE-4 edit, SLICE-5 bulk accept it), persisted on a fresh
`users` row."""
from fastapi.testclient import TestClient
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/auth/test/login",
json={"email": EMAIL},
headers={"X-Test-Auth-Secret": SECRET},
)
assert r.status_code == 200, r.text
# The session cookie now surfaces an authenticated owner.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == EMAIL
assert me["user"]["role"] == "owner"
assert me["user"]["permission_state"] == "granted"
# Idempotent: a second login reuses the same row (still owner).
r = client.post(
"/auth/test/login",
json={"email": EMAIL},
headers={"X-Test-Auth-Secret": SECRET},
)
assert r.status_code == 200, r.text
from app import db
rows = db.conn().execute(
"SELECT role, permission_state FROM users WHERE email = ? COLLATE NOCASE",
(EMAIL,),
).fetchall()
assert len(rows) == 1
assert rows[0]["role"] == "owner"
assert rows[0]["permission_state"] == "granted"
def test_test_login_is_case_insensitive_on_email(app_with_fake_gitea, monkeypatch):
"""The configured-email check matches case-insensitively, mirroring
how the rest of the auth stack treats email."""
from fastapi.testclient import TestClient
monkeypatch.setenv("E2E_TEST_AUTH_SECRET", SECRET)
monkeypatch.setenv("E2E_TEST_AUTH_EMAIL", EMAIL)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/auth/test/login",
json={"email": EMAIL.upper()},
headers={"X-Test-Auth-Secret": SECRET},
)
assert r.status_code == 200, r.text
+179
View File
@@ -0,0 +1,179 @@
"""Unit tests for `app.email_envelope.build_envelope` (v0.18.0 Slice 1).
These tests don't spin up the FastAPI app or touch the DB — they
exercise the helper directly. The integration tests in
test_otc_vertical / test_admin_create_user_invite_vertical /
test_notifications_vertical exercise the helper's *use* via the
shared `_SENT` buffer (the send path appends the envelope dict
before invoking the helper).
"""
from __future__ import annotations
from email.utils import parsedate_to_datetime
import pytest
from app.email_envelope import build_envelope
def _base_kwargs(**overrides):
base = dict(
to_address="recipient@example.com",
from_address="notifications@ohm.wiggleverse.org",
from_name="OHM",
subject="A test subject",
body_plain="Hello, world.\n",
)
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Always-present headers
# ---------------------------------------------------------------------------
def test_envelope_sets_from_to_subject():
msg = build_envelope(**_base_kwargs())
assert msg["To"] == "recipient@example.com"
assert msg["Subject"] == "A test subject"
# `From` is the display-form: "OHM <notifications@ohm.wiggleverse.org>".
assert "OHM" in msg["From"]
assert "<notifications@ohm.wiggleverse.org>" in msg["From"]
def test_envelope_sets_date_header_parseable():
msg = build_envelope(**_base_kwargs())
raw = msg["Date"]
assert raw, "Date header must be set"
# parsedate_to_datetime raises ValueError on malformed input.
dt = parsedate_to_datetime(raw)
assert dt is not None
def test_envelope_sets_message_id_with_from_domain_by_default():
msg = build_envelope(**_base_kwargs())
mid = msg["Message-ID"]
assert mid, "Message-ID must be set"
# Shape per RFC 5322 / make_msgid: <random@domain>
assert mid.startswith("<") and mid.endswith(">")
assert "@ohm.wiggleverse.org>" in mid
def test_envelope_message_id_domain_override():
msg = build_envelope(**_base_kwargs(msgid_domain="example.test"))
assert "@example.test>" in msg["Message-ID"]
def test_envelope_message_id_falls_back_to_localhost_if_from_has_no_at():
# Defensive: a malformed from_address shouldn't crash the helper.
msg = build_envelope(**_base_kwargs(from_address="bare-no-at-sign"))
assert "@localhost>" in msg["Message-ID"]
# ---------------------------------------------------------------------------
# Auto-Submitted (RFC 3834)
# ---------------------------------------------------------------------------
def test_envelope_sets_auto_submitted_for_transactional_default():
msg = build_envelope(**_base_kwargs())
assert msg["Auto-Submitted"] == "auto-generated"
def test_envelope_omits_auto_submitted_when_transactional_is_false():
msg = build_envelope(**_base_kwargs(is_transactional=False))
assert msg["Auto-Submitted"] is None
# ---------------------------------------------------------------------------
# Reply-To
# ---------------------------------------------------------------------------
def test_envelope_sets_reply_to_when_provided():
msg = build_envelope(**_base_kwargs(reply_to="ohm@wiggleverse.org"))
assert msg["Reply-To"] == "ohm@wiggleverse.org"
def test_envelope_omits_reply_to_when_absent():
msg = build_envelope(**_base_kwargs())
assert msg["Reply-To"] is None
# ---------------------------------------------------------------------------
# List-Unsubscribe (the headers RFC 8058 / Gmail-Yahoo care about)
# ---------------------------------------------------------------------------
def test_envelope_no_list_unsubscribe_when_neither_given():
"""OTC mail: the recipient explicitly requested the code; no
unsubscribe semantics. The header MUST be absent (presence would
imply OHM has the recipient on a list, which it doesn't)."""
msg = build_envelope(**_base_kwargs())
assert msg["List-Unsubscribe"] is None
assert msg["List-Unsubscribe-Post"] is None
def test_envelope_mailto_only_list_unsubscribe():
"""Admin invite / per-RFC invite: `mailto:` form only, no URL.
The recipient isn't a user yet, so there's no per-user opt-out
URL to flip; the operator handles ad-hoc opt-outs manually."""
msg = build_envelope(**_base_kwargs(
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
))
assert msg["List-Unsubscribe"] == "<mailto:ohm@wiggleverse.org?subject=remove>"
# NO List-Unsubscribe-Post when only a mailto is present — the
# one-click semantic requires a URL the MUA can POST to.
assert msg["List-Unsubscribe-Post"] is None
def test_envelope_full_one_click_list_unsubscribe():
"""Watcher notification / bundle: `mailto:` + signed-URL +
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`. Gmail and
Yahoo enforce this for bulk-adjacent mail per RFC 8058."""
msg = build_envelope(**_base_kwargs(
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
))
lu = msg["List-Unsubscribe"]
assert "<mailto:ohm@wiggleverse.org?subject=remove>" in lu
assert "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>" in lu
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
def test_envelope_url_only_list_unsubscribe_still_sets_post():
msg = build_envelope(**_base_kwargs(
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
))
assert msg["List-Unsubscribe"] == "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>"
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
# ---------------------------------------------------------------------------
# Body shape — plain-only vs multipart/alternative
# ---------------------------------------------------------------------------
def test_envelope_plain_only_body_is_text_plain():
msg = build_envelope(**_base_kwargs())
# No HTML alternative -> single-part text/plain.
assert msg.get_content_type() == "text/plain"
assert msg.get_content().strip() == "Hello, world."
def test_envelope_html_body_is_guarded_not_enabled():
# I3 (security-audit-0026): the HTML/multipart-alternative path is
# intentionally not enabled — passing body_html must fail loudly so
# a future caller can't silently ship unescaped user HTML (the C1
# stored-XSS class in the mail channel). When HTML mail is enabled
# deliberately, this test flips to assert the multipart shape.
with pytest.raises(NotImplementedError):
build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
def test_envelope_html_none_is_plain_only():
# The guard keys on `is not None`, so the default (None) stays the
# live plain-text path — exercised here to lock the boundary.
msg = build_envelope(**_base_kwargs(body_html=None))
assert msg.get_content_type() == "text/plain"
+42
View File
@@ -0,0 +1,42 @@
"""§22.4c — the unreviewed/reviewed_at/reviewed_by entry frontmatter fields."""
from __future__ import annotations
from app import entry as entry_mod
def test_parse_defaults_unreviewed_false_when_absent():
text = "---\nslug: ohm\ntitle: OHM\nstate: active\n---\n\nBody.\n"
e = entry_mod.parse(text)
assert e.unreviewed is False
assert e.reviewed_at is None
assert e.reviewed_by is None
def test_parse_reads_review_fields():
text = (
"---\nslug: ohm\ntitle: OHM\nstate: active\n"
"unreviewed: true\nreviewed_at: '2026-06-03'\nreviewed_by: ben\n---\n\nBody.\n"
)
e = entry_mod.parse(text)
assert e.unreviewed is True
assert e.reviewed_at == "2026-06-03"
assert e.reviewed_by == "ben"
def test_serialize_emits_review_fields_only_when_meaningful():
e = entry_mod.Entry(slug="a", title="A", state="super-draft")
assert "unreviewed" not in entry_mod.serialize(e)
assert "reviewed_at" not in entry_mod.serialize(e)
e2 = entry_mod.Entry(slug="b", title="B", state="active", unreviewed=True)
assert "unreviewed: true" in entry_mod.serialize(e2)
def test_round_trip_preserves_review_fields():
e = entry_mod.Entry(
slug="b", title="B", state="active",
unreviewed=False, reviewed_at="2026-06-03", reviewed_by="ben",
)
back = entry_mod.parse(entry_mod.serialize(e))
assert back.reviewed_at == "2026-06-03"
assert back.reviewed_by == "ben"
assert back.unreviewed is False
+87
View File
@@ -0,0 +1,87 @@
"""§22.4a SLICE-3 — pure facet field-set + filter/count (PUC-3).
Per docs/design/2026-06-06-configurable-collection-metadata.md §5.1, §6.4.
"""
from app import facets
PRIORITY = {"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}}
SCHEMA = {
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
"tags": {"type": "tags"},
"owner": {"type": "text"},
}
def _e(slug, state="active", malformed=False, **meta):
return {"slug": slug, "state": state, "metadata_malformed": malformed, "meta": meta}
def test_facet_fields_orders_declared_then_state_skips_text():
# enum + tags in declaration order, text skipped, state appended last.
assert facets.facet_fields(SCHEMA) == [
("priority", "enum"), ("tags", "tags"), ("state", "enum")]
def test_no_schema_yields_no_facets():
# INV-5: a collection with no fields has no facets (frontend keeps chips).
assert facets.facet_fields(None) == []
assert facets.facet_fields({}) == []
def test_filter_and_count_basic_counts():
entries = [
_e("a", priority="P0", tags=["checkout"]),
_e("b", priority="P0", tags=["cart"]),
_e("c", priority="P1", tags=["checkout", "cart"]),
]
items, fac = facets.filter_and_count(entries, SCHEMA, {})
assert {i["slug"] for i in items} == {"a", "b", "c"}
assert fac["priority"] == {"P0": 2, "P1": 1}
assert fac["tags"] == {"checkout": 2, "cart": 2}
assert fac["state"] == {"active": 3}
def test_filter_compose_or_within_and_across():
entries = [
_e("a", priority="P0", tags=["checkout"]), # P0 + checkout
_e("b", priority="P0", tags=["cart"]), # P0, no checkout
_e("c", priority="P1", tags=["checkout"]), # checkout, not P0
]
# priority=P0 AND tags=checkout → only "a".
items, _ = facets.filter_and_count(
entries, SCHEMA, {"priority": {"P0"}, "tags": {"checkout"}})
assert {i["slug"] for i in items} == {"a"}
def test_drilldown_counts_exclude_own_field_selection():
entries = [
_e("a", priority="P0"),
_e("b", priority="P1"),
_e("c", priority="P1"),
]
# With P0 selected, the priority facet still counts P1 over the set that
# ignores priority's own selection — so P1 stays switchable.
_, fac = facets.filter_and_count(entries, SCHEMA, {"priority": {"P0"}})
assert fac["priority"] == {"P0": 1, "P1": 2}
def test_malformed_toggle_narrows_items_and_counts():
entries = [
_e("a", state="active", malformed=True, priority="P9"),
_e("b", state="active", malformed=False, priority="P0"),
]
items, fac = facets.filter_and_count(entries, SCHEMA, {}, only_malformed=True)
assert {i["slug"] for i in items} == {"a"}
assert fac["priority"] == {"P9": 1}
def test_missing_value_contributes_no_facet_value():
entries = [_e("a", priority="P0"), _e("b")] # b has no priority
_, fac = facets.filter_and_count(entries, SCHEMA, {})
assert fac["priority"] == {"P0": 1}
def test_allowed_filter_keys():
assert facets.allowed_filter_keys(PRIORITY) == {"priority", "state",
"unreviewed", "malformed"}
+134
View File
@@ -0,0 +1,134 @@
"""§22.4a SLICE-3 integration — faceted list endpoint (PUC-3, §6.4).
Through the real API: schema-declared facets, filter params (OR within / AND
across), drill-down counts, malformed toggle, unknown-field 400, and meta_json
persistence. Reuses the fake-Gitea harness from test_metadata_cache.
"""
from __future__ import annotations
import asyncio
import json
import yaml
from fastapi.testclient import TestClient
from app import cache, db, gitea as gitea_mod
from app.config import load_config
from test_propose_vertical import ( # noqa: F401 (fixtures)
app_with_fake_gitea,
tmp_env,
)
# The fake-Gitea harness seeds a single project whose id is the literal
# 'default' (see test_s1_collection_grain_vertical), served at
# /api/projects/default/rfcs.
PID = "default"
def _refresh():
cfg = load_config()
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
def _set_default_fields_schema(schema):
db.conn().execute(
"UPDATE collections SET config_json = ? WHERE id = 'default'",
(json.dumps({"fields": schema}),))
def _seed(fake, slug, *, state="active", **front):
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
body = yaml.safe_dump(fm, sort_keys=False).strip()
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
def test_facets_and_counts_returned(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_default_fields_schema({
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
"tags": {"type": "tags"},
})
_seed(fake, "a", priority="P0", tags=["checkout"])
_seed(fake, "b", priority="P0", tags=["cart"])
_seed(fake, "c", priority="P1", tags=["checkout", "cart"])
_refresh()
res = client.get(f"/api/projects/{PID}/rfcs")
assert res.status_code == 200
body = res.json()
assert {i["slug"] for i in body["items"]} == {"a", "b", "c"}
assert body["facets"]["priority"] == {"P0": 2, "P1": 1}
assert body["facets"]["tags"] == {"checkout": 2, "cart": 2}
assert body["facets"]["state"] == {"active": 3}
def test_filter_params_compose(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_default_fields_schema({
"priority": {"type": "enum", "values": ["P0", "P1"]},
"tags": {"type": "tags"},
})
_seed(fake, "a", priority="P0", tags=["checkout"])
_seed(fake, "b", priority="P0", tags=["cart"])
_seed(fake, "c", priority="P1", tags=["checkout"])
_refresh()
res = client.get(
f"/api/projects/{PID}/rfcs",
params={"priority": "P0", "tags": "checkout"})
assert {i["slug"] for i in res.json()["items"]} == {"a"}
def test_unknown_filter_field_400(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_default_fields_schema(
{"priority": {"type": "enum", "values": ["P0"]}})
_seed(fake, "a", priority="P0")
_refresh()
res = client.get(f"/api/projects/{PID}/rfcs",
params={"nonsense": "x"})
assert res.status_code == 400
def test_malformed_toggle(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_default_fields_schema(
{"priority": {"type": "enum", "values": ["P0", "P1"]}})
_seed(fake, "good", priority="P0")
_seed(fake, "bad", priority="P9") # not in values → malformed (INV-3)
_refresh()
res = client.get(f"/api/projects/{PID}/rfcs",
params={"malformed": "true"})
slugs = {i["slug"] for i in res.json()["items"]}
assert slugs == {"bad"}
def test_no_schema_no_facets(app_with_fake_gitea):
# INV-5: the default document collection (no fields) returns empty facets.
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed(fake, "plain", tags=["whatever"])
_refresh()
body = client.get(f"/api/projects/{PID}/rfcs").json()
assert body["facets"] == {}
def test_meta_json_persisted_at_ingest(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
_seed(fake, "withmeta", priority="P0", tags=["x"])
_refresh()
row = db.conn().execute(
"SELECT meta_json FROM cached_rfcs WHERE slug = 'withmeta'"
).fetchone()
meta = json.loads(row["meta_json"])
assert meta["priority"] == "P0"
assert meta["tags"] == ["x"]
+324 -202
View File
@@ -1,29 +1,29 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
"""End-to-end integration tests for the §13 graduation flow under the
meta-only topology (SPEC §1, ROADMAP #36).
Walks the §13.3 transactional sequence end-to-end against the in-process
FakeGitea from test_propose_vertical.py:
Graduation is an in-place state flip on the meta entry no per-RFC repo
is created, the body is kept, and there is no multi-step transaction or
rollback (§13.3). These tests walk it against the in-process FakeGitea
from test_propose_vertical.py:
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
round-trips already proven by Slice 1 and exercised in
test_claim_opens_meta_pr below for the §13.1 surface itself).
* Seed an owned super-draft (the §13.1 claim flow is exercised
separately in test_claim_opens_meta_pr).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog.
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
inline. On success: per-RFC repo exists with RFC.md / README.md /
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
graduated, cached_rfcs.state is 'active'.
* §9.8 precondition gate refuses the start when a body-edit PR is open.
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
graduation unchanged the rfc_slug is the canonical key per §2.3,
so no data movement is needed.
* §9.8 pre-graduation history: the new RFC's /main response surfaces
edit-branch threads under `pre_graduation_history`.
the two-field dialog (integer id + owners; no repo name).
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
inline. On success: NO per-RFC repo, the meta entry is `state:
active` with the body KEPT and `repo` null, cached_rfcs.state flips
to 'active'.
* An open body-edit PR no longer blocks graduation (§9.8) they
coexist.
* An open-PR failure leaves the entry a super-draft (nothing created);
a merge failure cleans up the half-open PR/branch and leaves the
entry a super-draft.
* §13.4: chat threads + edit branches stay put the slug is the
canonical key per §2.3, so nothing moves at the flip.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
test can assert post-conditions on the same event loop tick. Production
clients use the spec-described SSE shape via `/graduate/progress`.
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
assert post-conditions on the same event loop tick.
"""
from __future__ import annotations
@@ -48,6 +48,18 @@ PITCH = (
)
def _entry_from_git(fake, slug, branch="main"):
"""§22.4a SLICE-4: read an entry's combined metadata+body from git via the
dual-read parser graduation/claim now write metadata to the sidecar and
keep the body in the `.md`, so an Entry is reconstructed from both."""
from app import metadata
md = fake.files[("wiggleverse", "meta", branch, f"rfcs/{slug}.md")]["content"]
sc = fake.files.get(
("wiggleverse", "meta", branch, f"rfcs/{slug}.meta.yaml"), {}).get("content")
e, _ = metadata.read_entry(md, sc, fallback_slug=slug)
return e
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
owners: list[str], arbiters: list[str] | None = None,
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
@@ -110,7 +122,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
# ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
"""Two-field dialog under meta-only: integer id + owners. No repo
name to validate (§13.2)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
@@ -121,57 +135,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
# Happy: a fresh RFC-0001.
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
assert r.status_code == 200, r.text
d = r.json()
assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True
# No repo field in the meta-only check response.
assert "repo" not in d
# ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
d = r.json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
# Repo name pattern error — leading dot.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": ".bad"})
d = r.json()
assert d["repo"]["ok"] is False
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo."""
flips false even with a valid id."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# No owners — simulates an unclaimed super-draft.
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
d = r.json()
assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge
PR, refresh cache. End state: cached_rfcs.state='active', the meta
entry's body is stripped, the per-RFC repo has RFC.md, the audit
log carries graduate_start graduate_complete bracketing the
per-step rows."""
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
"""The meta-only flip: open + merge a frontmatter PR. End state:
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
null, NO per-RFC repo exists, and the audit log carries graduate_start
graduate_pr_open graduate_pr_merge graduate_complete."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
@@ -186,60 +189,84 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
# No repo in the response, no per-RFC repo on Gitea.
assert "repo" not in d
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
assert not any(
k[1].startswith("rfc-0042") for k in fake.repos
), f"a per-RFC repo was created: {fake.repos}"
# 1. Per-RFC repo exists on Gitea.
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
# 2. Seed files landed on main.
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
assert "Open Human Model is a framework" in rfc_md
# 3. Meta entry body is stripped + frontmatter graduated.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text)
# Meta entry on main: state flipped (sidecar), body KEPT (.md), repo null.
graduated = _entry_from_git(fake, "ohm")
assert graduated.state == "active"
assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
assert graduated.repo is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == ""
# 5. cached_rfcs.state flipped to active via the inline refresh.
assert "Open Human Model is a framework" in graduated.body
# cached_rfcs flipped to active via the inline refresh; body intact.
cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
# cached body now mirrors RFC.md from the per-RFC repo.
assert cached["repo"] is None
assert "Open Human Model is a framework" in cached["body"]
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
kinds = [
r["action_kind"]
for r in db.conn().execute(
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
for needed in ("graduate_start", "graduate_repo_create",
"graduate_repo_seed", "graduate_pr_open",
for needed in ("graduate_start", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}"
# The retired per-repo steps must NOT appear.
for gone in ("graduate_repo_create", "graduate_repo_seed",
"graduate_repo_delete", "graduate_rollback"):
assert gone not in kinds, f"retired audit row present: {gone}"
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
graduation before the bot starts the sequence §13.3's rollback
complexity does not grow."""
def test_graduate_preserves_unknown_frontmatter_keys(app_with_fake_gitea):
"""§22.4a INV-7: a forward-compat / unknown frontmatter key on the
super-draft entry must ride through the graduation rebuild, not be dropped."""
from fastapi.testclient import TestClient
from app import entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH,
owners=["ben"], arbiters=["ben"])
# Inject an unknown key into the seeded entry's frontmatter.
key = ("wiggleverse", "meta", "main", "rfcs/ohm.md")
e = entry_mod.parse(fake.files[key]["content"])
e.extra["priority"] = "P1"
fake.files[key]["content"] = entry_mod.serialize(e)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post("/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "owners": ["ben"]})
assert r.status_code == 200, r.text
graduated = _entry_from_git(fake, "ohm")
assert graduated.state == "active"
assert graduated.extra.get("priority") == "P1"
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation the body is kept, so they coexist. /check stays
submittable and the flip succeeds."""
from fastapi.testclient import TestClient
from app import db
@@ -249,13 +276,11 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
# Cut an edit branch and open a body-edit PR.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
@@ -279,94 +304,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm dimension."},
).json()["pr_number"]
assert pr_number # PR is open
# /blocking-prs surfaces it.
# /check stays submittable despite the open body-edit PR.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["pr_number"] == pr_number
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
assert "blocking_prs" not in d
assert d["can_submit"] is True
# /check refuses can_submit.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["blocking_prs"]["ok"] is False
assert d["can_submit"] is False
# POST refuses with 409 — the bot never starts the sequence.
# The flip succeeds — coexists with the open body-edit PR.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
assert "blocking graduation" in r.text or "block" in r.text
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
"""Step 2 (seed files) fails partway → the orchestrator rolls back
step 1 (delete the repo) and records the rollback in the audit log.
The cached_rfcs row stays at 'super-draft'."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import Gitea, GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
# has already been created in step 1; the rollback must delete it.
orig_seed = Bot.seed_graduated_rfc
async def boom(self, *args, **kwargs):
raise GiteaError(500, "simulated seed failure for rollback test")
Bot.seed_graduated_rfc = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
"owners": ["ben"]},
)
finally:
Bot.seed_graduated_rfc = orig_seed
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is False
# Repo deleted as the rollback inverse.
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
# Meta entry unchanged.
assert r.json()["succeeded"] is True
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
# Audit log carries the rollback row.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_repo_create" in kinds
assert "graduate_repo_delete" in kinds
assert "graduate_rollback" in kinds
assert "graduate_complete" not in kinds
assert cached["state"] == "active"
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
1 (deleting the repo, which reclaims the seed commits at the same
time). The meta-repo entry is untouched."""
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
"""An open-PR failure creates nothing — the entry stays a super-draft
with its body intact and no graduation PR on the meta repo."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -387,19 +349,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
)
finally:
Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
# Meta entry's body still has the pitch (not stripped).
# Entry untouched: still super-draft, body intact on main.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
"""A merge failure leaves the flip PR open on its dash-suffixed
branch; the orchestrator closes the PR and deletes the branch so
failed attempts don't accumulate. The entry stays a super-draft —
the flip PR's commit was on a branch, not on main."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Entry stays super-draft on main (the flip never merged).
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "state: super-draft" in meta_text
# The dash-suffixed graduation branch was cleaned up.
grad_branches = [
name for (o, repo), branches in fake.branches.items()
if (o, repo) == ("wiggleverse", "meta")
for name in branches
if name.startswith("graduate-ohm-")
]
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_pr_open" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused."""
@@ -414,17 +449,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
)
st.finished = False
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
finally:
@@ -432,11 +464,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
"""§13.4: chat threads on the super-draft's canonical-body view
(`branch_name='main'`) are interpreted as the new RFC's main-thread
after graduation. The rows don't move — the rfc_slug is canonical
per §2.3 so the same thread surfaces from both before and after
the graduation."""
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
stay put across the flip the rfc_slug is canonical per §2.3 so the
same thread surfaces from /branches/main before and after graduation."""
from fastapi.testclient import TestClient
from app import db
@@ -448,9 +478,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Materialize a whole-doc main thread + a message on it. This
# mirrors what reading the canonical-body view would create
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
@@ -466,32 +493,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
(thread_id,),
)
# Graduate.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone()
assert row["branch_name"] == "main"
# The new RFC's main view surfaces the same thread id as its
# whole-doc main thread (the entry is now active, the branch
# 'main' now points at the per-RFC repo's main, but the
# `(rfc_slug, branch_name)` key remains the canonical anchor).
r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay
attached to their original branch_name and surface from the new
RFC's /main response under `pre_graduation_history`."""
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
"""§13.4 (meta-only): after graduation an edit branch is a *current*
branch of the now-active RFC it surfaces in the normal `branches`
list, and there is no separate `pre_graduation_history` set (that
affordance is legacy per-repo only)."""
from fastapi.testclient import TestClient
from app import db
@@ -501,10 +522,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
@@ -513,30 +532,136 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
VALUES (?, 'user', 2, 'note on an edit branch')
""",
(thread_id,),
)
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history.
r = client.get("/api/rfcs/ohm/main")
d = r.json()
d = client.get("/api/rfcs/ohm/main").json()
assert d["state"] == "active"
hist = d["pre_graduation_history"]
assert len(hist) >= 1
assert any(h["branch_name"] == branch for h in hist)
target = next(h for h in hist if h["branch_name"] == branch)
assert target["message_count"] >= 1
# The edit branch is a current branch; no pre-graduation hop.
assert d["pre_graduation_history"] == []
assert any(b["name"] == branch for b in d["branches"]), \
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
def test_graduate_check_accepts_blank_id(app_with_fake_gitea):
"""§13.2 (optional number): a blank id is VALID — it means "graduate
without a number." `can_submit` stays true (owners are set), and an
absent `id` param behaves the same as an explicit empty string."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Explicit empty id.
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
assert d["id"]["ok"] is True
assert d["id"]["error"] is None
assert d["can_submit"] is True
# No id param at all — same default-accepted shape.
d = client.get("/api/rfcs/ohm/graduate/check").json()
assert d["id"]["ok"] is True
assert d["can_submit"] is True
# A malformed (non-blank) id is still rejected.
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
entry to `active` with `id: null`. The slug is the canonical identifier;
the catalog shows the entry as active with no number, and the audit row
records rfc_id null."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
pitch=PITCH, owners=["ben"], arbiters=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
# Blank rfc_id → graduate without a number.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["succeeded"] is True
assert d["rfc_id"] is None
# Meta entry: active, id null, body kept, graduation stamped.
graduated = _entry_from_git(fake, "ohm")
assert graduated.state == "active"
assert graduated.id is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at
assert "Open Human Model is a framework" in graduated.body
# Cache flipped to active with a null rfc_id.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] is None
# Catalog: present as active, identified by slug (no number).
items = client.get("/api/rfcs").json()["items"]
ohm = next(i for i in items if i["slug"] == "ohm")
assert ohm["state"] == "active"
assert ohm["id"] is None
# Audit: graduate_complete with rfc_id null.
complete = db.conn().execute(
"""
SELECT details FROM actions
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
ORDER BY id DESC LIMIT 1
"""
).fetchone()
assert complete is not None
assert _json.loads(complete["details"])["rfc_id"] is None
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
"""Omitting the rfc_id field entirely is treated the same as blank —
graduates without a number so older clients that drop the field don't
break, and the supplied-number path stays exactly as before."""
from fastapi.testclient import TestClient
from app import entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
assert r.status_code == 200, r.text
assert r.json()["rfc_id"] is None
graduated = _entry_from_git(fake, "ohm")
assert graduated.state == "active"
assert graduated.id is None
def test_claim_opens_meta_pr(app_with_fake_gitea):
@@ -560,12 +685,9 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
d = r.json()
assert d["branch_name"] == "claim/ohm"
# The PR body's diff carries Alice in owners.
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text)
ent = _entry_from_git(fake, "ohm", branch="claim/ohm")
assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone()
+9 -10
View File
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
"""§19.2 candidate Slice 8 settles: when graduation rolls back
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
deleted alongside the PR close so failed-graduation branches
don't accumulate on the meta repo across retries."""
"""Meta-only (§13.3): when the flip's merge fails after the PR is
open, the orchestrator closes the PR and deletes its
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
on the meta repo across retries."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
# already landed and the rollback exercises the branch cleanup.
# Force a merge_pr failure so the flip PR (open_pr) has already
# landed and the cleanup exercises the branch deletion.
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure for rollback test")
raise GiteaError(502, "simulated merge failure for cleanup test")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# The dash-suffixed graduation branch was deleted on rollback.
# The dash-suffixed graduation branch was deleted on cleanup.
meta_branches = fake.branches[("wiggleverse", "meta")]
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
assert graduation_branches == [], (
@@ -0,0 +1,43 @@
"""§22.4b — a project with initial_state='active' lands new entries active +
unreviewed; the default 'super-draft' project is unchanged."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _propose(client):
return client.post("/api/rfcs/propose", json={
"title": "Active Lander", "slug": "active-lander",
"pitch": "Lands active.", "tags": [],
})
def test_super_draft_default_unchanged(app_with_fake_gitea):
from app import entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert _propose(client).status_code == 200
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
e = entry_mod.parse(f["content"])
assert e.state == "super-draft"
assert e.unreviewed is False
def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
db.conn().execute("UPDATE collections SET initial_state='active' WHERE project_id='default'")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert _propose(client).status_code == 200
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
e = entry_mod.parse(f["content"])
assert e.state == "active"
assert e.unreviewed is True
@@ -0,0 +1,339 @@
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
A user who knows a (gated) scope exists asks to join it, naming a desired role;
the request is recorded and fanned out to that scope's Owners *across the
subtree* (the cross-collection inbox, §22.11). An Owner accepts which writes
the `memberships` row via memberships.grant or declines, and the requester is
§15-notified either way.
Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
and test_s4_invitations_vertical.py (the scope/membership world-builders).
World: project "ohm" owns collections "model" (document, gated) and "features"
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
(no scope role) the would-be joiner.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# World-builders (mirror the S4 vertical)
# ---------------------------------------------------------------------------
def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
"VALUES (?, ?, ?, ?, datetime('now'))",
(pid, pid.capitalize(), content_repo, visibility),
)
def _collection(cid: str, project_id: str, *, ctype: str = "document",
visibility: str = "gated") -> None:
db.conn().execute(
"INSERT OR REPLACE INTO collections "
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
(cid, project_id, ctype, cid, visibility, cid.capitalize()),
)
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
db.conn().execute(
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES (?, ?, ?, ?)",
(scope_type, scope_id, user_id, role),
)
def _membership(user_id: int):
rows = db.conn().execute(
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
(user_id,),
).fetchall()
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
def _join_requests(scope_type: str, scope_id: str):
rows = db.conn().execute(
"SELECT id, requester_user_id, requested_role, status, granted_role "
"FROM join_requests WHERE scope_type = ? AND scope_id = ?",
(scope_type, scope_id),
).fetchall()
return [dict(r) for r in rows]
def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
return {
r["recipient_user_id"]
for r in db.conn().execute(
"SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
(event_kind,),
)
}
def _seed_world() -> None:
_project("ohm", "gated")
_collection("model", "ohm", ctype="document")
_collection("features", "ohm", ctype="bdd")
provision_user_row(user_id=2, login="ben", role="contributor") # the joiner
provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features)
provision_user_row(user_id=5, login="eve", role="contributor") # project Owner
provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner
provision_user_row(user_id=7, login="ada", role="admin") # deployment admin
_grant("project", "ohm", 5, "owner")
_grant("collection", "features", 4, "owner")
_grant("global", "*", 6, "owner")
def _login(client, uid: int, login: str, role: str = "contributor") -> None:
sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)
# ---------------------------------------------------------------------------
# Request → cross-collection fan-out
# ---------------------------------------------------------------------------
def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
"""A request to join a collection lands a row and notifies every Owner whose
reach covers it the collection's Owner, the project's Owner, a global
Owner, and the deployment admin (the cross-collection inbox) never the
requester."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r = client.post(
"/api/scopes/collection/features/join-requests",
json={"role": "contributor", "message": "I work on BDD corpora."},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "pending"
reqs = _join_requests("collection", "features")
assert len(reqs) == 1
assert reqs[0]["requester_user_id"] == 2
assert reqs[0]["requested_role"] == "contributor"
assert reqs[0]["status"] == "pending"
# Owners across the subtree are notified; ben (requester) is not.
recips = _join_notif_recipients()
assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada
assert 2 not in recips
def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
"""A project-scope request reaches the project's Owners + global Owners +
admin, but NOT a collection-only Owner (their reach doesn't cover the
project)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r = client.post(
"/api/scopes/project/ohm/join-requests",
json={"role": "owner"},
)
assert r.status_code == 200, r.text
recips = _join_notif_recipients()
assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin)
assert 4 not in recips # dan is only a collection Owner
# ---------------------------------------------------------------------------
# Accept → writes membership + notifies
# ---------------------------------------------------------------------------
def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
"""The collection Owner accepts; a `memberships` row is written at the
requested scope/role and the requester gets a join_request_accepted inbox
row."""
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post(
"/api/scopes/collection/features/join-requests",
json={"role": "contributor"},
)
req_id = _join_requests("collection", "features")[0]["id"]
# dan (collection Owner of features) accepts.
_login(client, 4, "dan")
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
json={},
)
assert r.status_code == 200, r.text
assert r.json()["granted_role"] == "contributor"
# ben now holds the collection role and can contribute there.
assert ("collection", "features", "contributor") in _membership(2)
ben = auth.SessionUser(
user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
email="ben@test", avatar_url="", role="contributor", permission_state="granted",
)
assert auth.can_contribute_in_collection(ben, "features") is True
assert auth.can_contribute_in_collection(ben, "model") is False
# the row is closed; the requester is notified.
assert _join_requests("collection", "features")[0]["status"] == "accepted"
_login(client, 2, "ben")
inbox = client.get("/api/notifications").json()["items"]
accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
assert accepted, inbox
assert "Features" in accepted[0]["summary"]
def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
"""A request for Owner may be accepted as RFC Contributor — the Owner narrows
the grant; the membership row carries the granted (not requested) role."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
req_id = _join_requests("collection", "features")[0]["id"]
_login(client, 5, "eve") # project Owner — reach covers the collection
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
json={"role": "contributor"},
)
assert r.status_code == 200, r.text
assert ("collection", "features", "contributor") in _membership(2)
assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"
def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "features")[0]["id"]
_login(client, 4, "dan")
r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
assert r.status_code == 200, r.text
assert _membership(2) == set()
assert _join_requests("collection", "features")[0]["status"] == "declined"
_login(client, 2, "ben")
inbox = client.get("/api/notifications").json()["items"]
assert any(n["event_kind"] == "join_request_declined" for n in inbox)
# ---------------------------------------------------------------------------
# Gates & guards
# ---------------------------------------------------------------------------
def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r1.status_code == 200, r1.text
r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r2.status_code == 409, r2.text
def test_existing_member_cannot_request(app_with_fake_gitea):
"""dan already owns the collection — there is nothing to request (409)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 4, "dan")
r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
assert r.status_code == 409, r.text
def test_non_owner_cannot_accept(app_with_fake_gitea):
"""A plain requester (or any non-Owner) is refused the accept action."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "features")[0]["id"]
# provision a second plain account that tries to accept
provision_user_row(user_id=12, login="mal", role="contributor")
_login(client, 12, "mal")
r = client.post(
f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
)
assert r.status_code == 403, r.text
assert _membership(2) == set()
def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
"""dan owns 'features' only; a request to join 'model' is not his to act on."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
req_id = _join_requests("collection", "model")[0]["id"]
_login(client, 4, "dan")
r = client.post(
f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
)
assert r.status_code == 403, r.text
def test_unknown_scope_404(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
_login(client, 2, "ben")
assert client.post(
"/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
).status_code == 404
assert client.post(
"/api/scopes/project/nope/join-requests", json={"role": "contributor"}
).status_code == 404
# 'global' is not a join-able scope_type.
assert client.post(
"/api/scopes/global/*/join-requests", json={"role": "contributor"}
).status_code == 404
def test_join_target_reports_eligibility(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_world()
# ben: eligible (granted, no role).
_login(client, 2, "ben")
t = client.get("/api/scopes/collection/features/join-target").json()
assert t["eligible"] is True
assert t["name"] == "Features"
assert t["current_role"] is None
# after requesting, already_requested flips and eligible drops.
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
t2 = client.get("/api/scopes/collection/features/join-target").json()
assert t2["already_requested"] is True
assert t2["eligible"] is False
# dan: already a member → ineligible with current_role.
_login(client, 4, "dan")
t3 = client.get("/api/scopes/collection/features/join-target").json()
assert t3["eligible"] is False
assert t3["current_role"] == "owner"
+68
View File
@@ -0,0 +1,68 @@
"""§22.4c — owner/admin mark-reviewed clears the flag; catalog unreviewed filter."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _seed_unreviewed_active(fake, slug="feat"):
"""Put an active+unreviewed entry on the meta repo main + cache."""
from app import cache, entry as entry_mod
body = entry_mod.serialize(entry_mod.Entry(
slug=slug, title="Feat", state="active", unreviewed=True,
owners=["ben"], proposed_by="ben",
))
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": body, "sha": "s1"}
cache._upsert_cached_rfc(entry_mod.parse(body), body_sha="s1")
def test_catalog_unreviewed_filter(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_unreviewed_active(fake, "feat")
from app import cache, entry as entry_mod
ok = entry_mod.serialize(entry_mod.Entry(slug="ok", title="OK", state="active", owners=["ben"]))
fake.files[("wiggleverse", "meta", "main", "rfcs/ok.md")] = {"content": ok, "sha": "s2"}
cache._upsert_cached_rfc(entry_mod.parse(ok), body_sha="s2")
r = client.get("/api/rfcs", params={"unreviewed": "true"})
slugs = {i["slug"] for i in r.json()["items"]}
assert slugs == {"feat"}
def test_mark_reviewed_clears_flag(app_with_fake_gitea):
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_unreviewed_active(fake, "feat")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
assert r.status_code == 200
row = db.conn().execute(
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug='feat'"
).fetchone()
assert row["unreviewed"] == 0
assert row["reviewed_by"] == "ben"
assert row["reviewed_at"] # provenance stamped
# git-side (§22.4a SLICE-4): the cleared flag now lands in the metadata
# sidecar and the `.md` is lazy-migrated to a clean body-only file (INV-2).
import yaml
sidecar = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.meta.yaml")]["content"]
sc = yaml.safe_load(sidecar)
assert not sc.get("unreviewed") # cleared (omitted when False)
assert sc.get("reviewed_by") == "ben"
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
assert "---" not in written # body-only, no frontmatter
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_unreviewed_active(fake, "feat")
provision_user_row(user_id=2, login="carol", role="contributor")
sign_in_as(client, user_id=2, gitea_login="carol", display_name="Carol", role="contributor")
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
assert r.status_code == 403
+223
View File
@@ -0,0 +1,223 @@
"""SLICE-1 unit tests — sidecar metadata: dual-read, unknown-key preservation,
frontmatter stripping, malformed detection.
Pure functions only (no DB / no Gitea). Per
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-1) and
INV-6 (dual-read), INV-7 (unknown keys ride along), INV-2 (clean body).
"""
from __future__ import annotations
import yaml
from app import entry as entry_mod
from app import metadata
LEGACY_MD = """---
slug: view-metrics
title: View today's metrics
state: active
owners:
- ben.stull
tags:
- dashboard
- analytics
priority: P1
owner: hasan
---
This is the prose body.
Second paragraph.
"""
# ---- INV-7: unknown keys ride along ----
def test_parse_preserves_unknown_keys_in_extra():
e = entry_mod.parse(LEGACY_MD)
assert e.extra == {"priority": "P1", "owner": "hasan"}
def test_serialize_round_trip_preserves_unknown_keys():
e = entry_mod.parse(LEGACY_MD)
text = entry_mod.serialize(e)
e2 = entry_mod.parse(text)
assert e2.extra == {"priority": "P1", "owner": "hasan"}
assert e2.tags == ["dashboard", "analytics"]
assert e2.owners == ["ben.stull"]
def test_known_keys_never_leak_into_extra():
e = entry_mod.parse(LEGACY_MD)
for known in ("slug", "title", "state", "owners", "tags"):
assert known not in e.extra
# ---- metadata_dict / sidecar_yaml ----
def test_metadata_dict_merges_known_and_extra():
e = entry_mod.parse(LEGACY_MD)
d = metadata.metadata_dict(e)
assert d["slug"] == "view-metrics"
assert d["title"] == "View today's metrics"
assert d["state"] == "active"
assert d["tags"] == ["dashboard", "analytics"]
# forward-compat keys present
assert d["priority"] == "P1"
assert d["owner"] == "hasan"
def test_sidecar_yaml_is_parseable_and_has_no_frontmatter_fences():
e = entry_mod.parse(LEGACY_MD)
sc = metadata.sidecar_yaml(e)
assert "---" not in sc.splitlines()[0]
loaded = yaml.safe_load(sc)
assert loaded["slug"] == "view-metrics"
assert loaded["priority"] == "P1"
# ---- strip_frontmatter (INV-2) ----
def test_strip_frontmatter_removes_leading_block():
body = metadata.strip_frontmatter(LEGACY_MD)
assert body.startswith("This is the prose body.")
assert "slug:" not in body
assert "priority:" not in body
def test_strip_frontmatter_passthrough_when_no_frontmatter():
plain = "Just a body.\n\nNo frontmatter here.\n"
assert metadata.strip_frontmatter(plain).strip() == plain.strip()
# ---- parse_sidecar (malformed detection, INV-3) ----
def test_parse_sidecar_good():
values, malformed = metadata.parse_sidecar("slug: a\ntitle: A\npriority: P0\n")
assert malformed is False
assert values == {"slug": "a", "title": "A", "priority": "P0"}
def test_parse_sidecar_non_mapping_is_malformed():
values, malformed = metadata.parse_sidecar("- just\n- a\n- list\n")
assert malformed is True
assert values == {}
def test_parse_sidecar_invalid_yaml_is_malformed():
values, malformed = metadata.parse_sidecar("slug: : : not yaml\n bad: [unclosed\n")
assert malformed is True
assert values == {}
def test_parse_sidecar_empty_is_empty_not_malformed():
values, malformed = metadata.parse_sidecar("")
assert malformed is False
assert values == {}
# ---- read_entry dual-read equivalence (INV-6) ----
def test_dual_read_sidecar_matches_legacy():
legacy_entry, legacy_bad = metadata.read_entry(LEGACY_MD, None)
# The migrated form: body-only .md + a sidecar holding the metadata.
migrated_md = metadata.strip_frontmatter(LEGACY_MD)
sidecar_text = metadata.sidecar_yaml(legacy_entry)
sidecar_entry, sidecar_bad = metadata.read_entry(migrated_md, sidecar_text)
assert legacy_bad is False
assert sidecar_bad is False
# Identical resulting records (INV-6).
assert sidecar_entry.slug == legacy_entry.slug
assert sidecar_entry.title == legacy_entry.title
assert sidecar_entry.state == legacy_entry.state
assert sidecar_entry.owners == legacy_entry.owners
assert sidecar_entry.tags == legacy_entry.tags
assert sidecar_entry.extra == legacy_entry.extra
assert sidecar_entry.body.strip() == legacy_entry.body.strip()
def test_read_entry_sidecar_takes_precedence_over_md_frontmatter():
# A not-yet-migrated .md still carrying frontmatter, plus a sidecar that
# disagrees: the sidecar wins for metadata; the body comes from the .md.
md_with_fm = "---\nslug: old\ntitle: Old Title\nstate: super-draft\n---\n\nBody.\n"
sidecar = "slug: new\ntitle: New Title\nstate: active\n"
e, malformed = metadata.read_entry(md_with_fm, sidecar)
assert malformed is False
assert e.title == "New Title"
assert e.state == "active"
assert e.body.strip() == "Body."
def test_read_entry_malformed_sidecar_still_loads_entry():
# INV-3: a malformed sidecar never hard-fails the read. The entry loads
# (from the .md frontmatter if present) and is flagged malformed.
md = "---\nslug: x\ntitle: X\nstate: active\n---\n\nBody.\n"
e, malformed = metadata.read_entry(md, "- not a mapping\n")
assert malformed is True
assert e.slug == "x"
assert e.title == "X"
assert e.body.strip() == "Body."
# ---- dual-read robustness: degenerate sidecars never drop the entry ----
def test_empty_sidecar_falls_back_to_md_frontmatter():
# An empty sidecar has no metadata to override with — keep the .md's.
md = "---\nslug: keep\ntitle: Keep Me\nstate: active\n---\n\nBody.\n"
e, malformed = metadata.read_entry(md, "", fallback_slug="keep")
assert malformed is False
assert e.slug == "keep"
assert e.title == "Keep Me"
def test_malformed_sidecar_on_body_only_md_loads_with_fallback_slug():
# The .md is already body-only (migrated) and the sidecar is corrupt:
# the entry must still load (INV-3), taking its slug from the filename stem.
e, malformed = metadata.read_entry("Just a body.\n", "- a\n- list\n", fallback_slug="foo")
assert malformed is True
assert e.slug == "foo"
def test_slugless_sidecar_uses_fallback_slug():
md = "Body only.\n"
sidecar = "title: No Slug Here\nstate: active\n"
e, malformed = metadata.read_entry(md, sidecar, fallback_slug="bar")
assert malformed is False
assert e.slug == "bar"
assert e.title == "No Slug Here"
# ---- sidecar filename helpers ----
def test_sidecar_filename_helpers():
assert metadata.sidecar_name("view-metrics") == "view-metrics.meta.yaml"
assert metadata.is_sidecar("view-metrics.meta.yaml") is True
assert metadata.is_sidecar("view-metrics.md") is False
assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics"
# ---- SLICE-4: sidecar_path_for + apply_values ----
def test_sidecar_path_for_derives_sibling():
assert metadata.sidecar_path_for("rfcs/alpha.md") == "rfcs/alpha.meta.yaml"
assert metadata.sidecar_path_for("x/y/beta.md") == "x/y/beta.meta.yaml"
def test_apply_values_updates_known_and_extra_fields():
e = entry_mod.parse(LEGACY_MD) # has tags + extra priority/owner
e2 = metadata.apply_values(e, {"tags": ["x"], "priority": "P0", "owner": "sam"})
assert e2.tags == ["x"]
assert e2.extra["priority"] == "P0"
assert e2.extra["owner"] == "sam"
# body preserved unchanged
assert e2.body == e.body
def test_apply_values_preserves_unspecified_keys():
e = entry_mod.parse(LEGACY_MD)
e2 = metadata.apply_values(e, {"priority": "P0"})
assert e2.tags == e.tags # untouched
assert e2.extra["owner"] == "hasan" # untouched
@@ -0,0 +1,232 @@
"""SLICE-5 — bulk metadata edit endpoint (PUC-2, §6.4/§6.5).
Through the real API: contributor+ gating (INV-4), set/add/remove ops,
validation at the write boundary, one commit for N sidecars (D7), and
partial-rejection reporting. Reuses the fake-Gitea harness.
"""
from __future__ import annotations
import asyncio
import json
import yaml
from fastapi.testclient import TestClient
from app import cache, db, gitea as gitea_mod
from app.config import load_config
from test_propose_vertical import ( # noqa: F401 (fixtures)
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
PID = "default"
CID = "default"
BASE = f"/api/projects/{PID}/collections/{CID}"
def _refresh():
cfg = load_config()
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
def _set_fields(schema):
db.conn().execute(
"UPDATE collections SET config_json = ? WHERE id = 'default'",
(json.dumps({"fields": schema}),))
def _seed_legacy(fake, slug, *, state="active", **front):
fm = {"slug": slug, "title": slug.title(), "state": state, **front}
body = yaml.safe_dump(fm, sort_keys=False).strip()
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
"content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}
def _login_owner(client):
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
def _has_sidecar(fake, slug):
return ("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml") in fake.files
def _sidecar(fake, slug):
return yaml.safe_load(
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")]["content"])
# ---- Task 1: happy path, one commit ----
def test_bulk_set_applies_to_all_and_one_commit(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
_seed_legacy(fake, "a", priority="P2")
_seed_legacy(fake, "b", priority="P1")
_refresh()
_login_owner(client)
commits_before = fake.change_files_calls
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a", "b"], "op": "set",
"field": "priority", "value": "P0"})
assert r.status_code == 200, r.text
body = r.json()
assert set(body["applied"]) == {"a", "b"}
assert body["rejected"] == []
assert body["committed"] is True
# exactly one ChangeFiles commit covered both entries (D7)
assert fake.change_files_calls - commits_before == 1
assert _sidecar(fake, "a")["priority"] == "P0"
assert _sidecar(fake, "b")["priority"] == "P0"
# ---- Task 2: add/remove tags ----
def test_bulk_add_tag(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"tags": {"type": "tags"}})
_seed_legacy(fake, "a", tags=["x"])
_seed_legacy(fake, "b", tags=["x", "y"])
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a", "b"], "op": "add",
"field": "tags", "value": "y"})
assert r.status_code == 200, r.text
assert set(r.json()["applied"]) == {"a", "b"}
# "a" gained y; "b" already had y (no-op write skipped → no sidecar written)
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
assert not _has_sidecar(fake, "b")
def test_bulk_remove_tag(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"tags": {"type": "tags"}})
_seed_legacy(fake, "a", tags=["x", "y"])
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "remove",
"field": "tags", "value": "x"})
assert r.status_code == 200, r.text
assert r.json()["applied"] == ["a"]
assert _sidecar(fake, "a")["tags"] == ["y"]
def test_bulk_set_scalar_on_tags_rejected(app_with_fake_gitea):
# A scalar `set` onto a tags field must reject, not char-split into a list.
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"tags": {"type": "tags"}})
_seed_legacy(fake, "a", tags=["x"])
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "set",
"field": "tags", "value": "checkout"})
assert r.status_code == 200, r.text
assert r.json()["applied"] == []
assert len(r.json()["rejected"]) == 1
assert r.json()["committed"] is False
assert not _has_sidecar(fake, "a")
def test_bulk_set_list_on_tags_ok(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"tags": {"type": "tags"}})
_seed_legacy(fake, "a", tags=["x"])
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "set",
"field": "tags", "value": ["x", "y"]})
assert r.status_code == 200, r.text
assert r.json()["applied"] == ["a"]
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
def test_bulk_add_remove_requires_tags_field(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
_seed_legacy(fake, "a", priority="P0")
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "add",
"field": "priority", "value": "z"})
assert r.status_code == 422, r.text
# ---- Task 3: partial rejection, authz, validation guards ----
def test_bulk_partial_reject_missing_entry(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
_seed_legacy(fake, "a", priority="P1")
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a", "ghost"], "op": "set",
"field": "priority", "value": "P0"})
assert r.status_code == 200, r.text
body = r.json()
assert body["applied"] == ["a"]
assert body["rejected"] == [{"slug": "ghost", "reason": "not found"}]
assert body["committed"] is True
assert _sidecar(fake, "a")["priority"] == "P0"
def test_bulk_invalid_value_rejects_all_no_commit(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
_seed_legacy(fake, "a", priority="P1")
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "set",
"field": "priority", "value": "ZZZ"})
assert r.status_code == 200, r.text
assert r.json()["applied"] == []
assert len(r.json()["rejected"]) == 1
assert r.json()["committed"] is False
assert not _has_sidecar(fake, "a")
def test_bulk_forbidden_for_anonymous(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
_seed_legacy(fake, "a", priority="P0")
_refresh()
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "set",
"field": "priority", "value": "P0"})
assert r.status_code == 403, r.text
def test_bulk_unknown_field_op_and_empty(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"priority": {"type": "enum", "values": ["P0"]}})
_seed_legacy(fake, "a", priority="P0")
_refresh()
_login_owner(client)
assert client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "set",
"field": "nope", "value": "P0"}).status_code == 422
assert client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "frobnicate",
"field": "priority", "value": "P0"}).status_code == 422
assert client.post(f"{BASE}/meta/bulk",
json={"slugs": [], "op": "set",
"field": "priority", "value": "P0"}).status_code == 422

Some files were not shown because too many files have changed in this diff Show More