Skip to content

Commit c0b059a

Browse files
jsilvanusclaude
andauthored
Public repo sharing (Phases 126-127) (#113)
* Implement Phase 126-127: public repo sharing (visibility, attach-as-reader, first-index gate, refresh throttle) Adds repos.visibility/owner_user_id and repo_grants.source columns, a gitsema repos visibility CLI command, and registration-flow logic in POST /api/v1/remote/index for auto-granting read access on public repos, gated by auth.allowPublicAutoIndex and throttled by auth.minReindexIntervalSeconds. Fixes a registry-DB/active-DB FK mismatch discovered during testing: registry.db (clone/index-path bookkeeping) and the active session DB (auth/grants) are independent SQLite files with separate FK-enforced users tables, so ownerUserId is now mirrored only into the active DB rather than written to both. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYNogpqPb8oyTF3B5Ebu5p * refactor: simplify public-repo-sharing throttle/grant logic and test fixtures Hoist the duplicated non-owner-on-public-repo guard into a single named boolean, add TTL cleanup for the reindex throttle map (matching the existing jobs map pattern), and extract repeated test fixture setup into a shared helper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYNogpqPb8oyTF3B5Ebu5p * docs: require simplify passes to log deferred findings in feature-ideas.md Adds a CLAUDE.md rule so skipped /simplify findings aren't lost to chat history, and backfills the two deferred findings from the Phase 126/127 public-repo-sharing simplify pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYNogpqPb8oyTF3B5Ebu5p * docs: note send_later/CI-webhook gaps for PR babysitting in this environment Records that this environment lacks send_later and that CI success doesn't trigger a webhook, so PR-watching sessions need a manual ~7-minute poll loop instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYNogpqPb8oyTF3B5Ebu5p --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 79210ed commit c0b059a

17 files changed

Lines changed: 627 additions & 26 deletions

File tree

.changeset/public-repo-sharing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitsema": minor
3+
---
4+
5+
Add public repo sharing: persisted repos can now be flagged `public` (`gitsema repos visibility <repo-id> public|private`), auto-granting `read` access to non-owner callers who index an existing public repo, gated by a first-index allow-list (`auth.allowPublicAutoIndex`/`GITSEMA_PUBLIC_AUTO_INDEX`) and a per-user re-index throttle (`auth.minReindexIntervalSeconds`/`GITSEMA_MIN_REINDEX_INTERVAL_SECONDS`).

CLAUDE.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,30 @@ When implementing a new feature or phase:
2727

2828
---
2929

30+
## Simplify passes
31+
32+
When running a `/simplify` pass, any finding that gets **skipped** (out of scope,
33+
too large a refactor, judged not worth doing now) must be written up as an entry
34+
in **`docs/feature-ideas.md`** rather than just mentioned in the chat summary —
35+
otherwise the finding is lost once the session ends.
36+
37+
---
38+
39+
## PR babysitting in this environment
40+
41+
The `send_later` tool (claude-code-remote MCP server) is **not available** in
42+
this environment. CI turning green also does **not** trigger a webhook event —
43+
only CI failures, new review comments, and similar activity do. This means a
44+
subscribed PR can sit at "CI passed" indefinitely with no event to notice it.
45+
46+
When babysitting/watching a PR here, compensate by running a short polling
47+
wait (e.g. a backgrounded `sleep ~7m` via Bash `run_in_background`, repeated as
48+
needed) and re-checking CI status (`pull_request_read``get_status` /
49+
`get_check_runs`) after each wait, instead of relying on `send_later` or
50+
webhook delivery alone.
51+
52+
---
53+
3054
## Releases & changesets
3155

3256
This repo uses [changesets](https://github.com/changesets/changesets) for versioning, `CHANGELOG.md` generation, and npm publishing (OIDC trusted publishing — no npm token).
@@ -460,7 +484,7 @@ gitsema index
460484

461485
**Pluggable storage backends (Phase 101–103):** all reads/writes go through async `MetadataStore` / `VectorStore` / `FtsStore` interfaces (`src/core/storage/types.ts`). The default `sqlite` backend wraps the schema below; `postgres` routes metadata + FTS through Postgres (pgvector for vectors), and `qdrant` uses Qdrant for vectors with Postgres for metadata/FTS. Select via `storage.*` config or `GITSEMA_STORAGE_*` env vars (see Configuration), inspect with `gitsema storage info`, and copy between backends with `gitsema storage migrate`.
462486

463-
**Schema overview (current schema v30):**
487+
**Schema overview (current schema v31):**
464488

465489
| Table | Purpose |
466490
|---|---|
@@ -496,6 +520,8 @@ gitsema index
496520
| `repo_grants` | Per-user repo access grants (`read`/`write`/`owner`, optional branch-glob pattern); replaces the binary `repo_tokens` model for new deployments; added in v28 (Phase 123, multi-tenant-auth §5 Phase B) |
497521
| `sso_identities` | Linked external OIDC/SSO identities (`provider` + `external_id``user_id`, unique per identity); added in v29 (Phase 124, multi-tenant-auth §5 Phase C) |
498522
| `audit_log` | Identity/authorization audit trail — grant create/revoke, token create/revoke, login success/failure, org membership changes, repo org moves; no FK constraints (historical record outlives referenced rows); added in v30 (Phase 125, multi-tenant-auth §5 Phase D) |
523+
| `repos.visibility` / `repos.owner_user_id` | Repo visibility flag (`private`/`public`) and first-claimer owner; added in v31 (Phase 126, public-repo-sharing) |
524+
| `repo_grants.source` | Provenance of an auto-issued grant, e.g. `auto-public` for attach-as-reader grants; added in v31 (Phase 126, public-repo-sharing) |
499525

500526
**FTS5 note:** Blobs indexed before Phase 11 have no FTS5 content. `--hybrid` search only applies to blobs with FTS5 entries. `--include-content` in evolution dumps also depends on FTS5 content. Use `gitsema backfill-fts` to populate FTS5 content for older index entries.
501527

@@ -519,7 +545,8 @@ gitsema index
519545
- v27 → v28: Added `orgs`, `org_members`, `repo_grants` tables (+ indexes) and a `repos.org_id` column for org/grant authorization (Phase 123 / multi-tenant-auth §5 Phase B)
520546
- v28 → v29: Added `sso_identities` table (+ indexes) for linked external OIDC/SSO identities (Phase 124 / multi-tenant-auth §5 Phase C)
521547
- v29 → v30: Added `audit_log` table (+ indexes) for the identity/authorization audit trail (Phase 125 / multi-tenant-auth §5 Phase D)
522-
- **Current version: 30**
548+
- v30 → v31: Added `visibility` and `owner_user_id` columns (+ index) to `repos`, and a `source` column to `repo_grants`, for public repo sharing (Phase 126 / public-repo-sharing)
549+
- **Current version: 31**
523550

524551
Schema changes require updating both `src/core/db/schema.ts` and the migration logic in `src/core/db/sqlite.ts`.
525552

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ All commands support a top-level `--verbose` flag (or `GITSEMA_VERBOSE=1`) for d
124124
| `gitsema repos grants <repo-id>` | List grants on a repo (operator-only) |
125125
| `gitsema repos revoke <repo-id> <username>` | Revoke a user's grants on a repo (operator-only) |
126126
| `gitsema repos move-to-org <repo-id> <org>` | Move a repo to a different org; grants survive untouched (operator-only) |
127+
| `gitsema repos visibility <repo-id> public\|private` | Set a persisted repo's visibility flag, gating attach-as-reader auto-grants (operator-only) |
127128
| `gitsema auth sso link <provider> <external-id> <username>` | Link an external SSO/OIDC identity to an existing user; provider must be in `GITSEMA_SSO_PROVIDERS` (operator-only) |
128129
| `gitsema auth sso unlink <provider> <external-id>` | Unlink an external identity (operator-only) |
129130
| `gitsema auth sso list <username>` | List SSO identities linked to a user (operator-only) |
@@ -247,6 +248,17 @@ Manage persisted repos with `gitsema repos list-persisted` and
247248
`gitsema repos remove <repoId> [--purge]`. See
248249
[`docs/features.md`](docs/features.md#persistent-server-side-repo-storage) for details.
249250

251+
**Public repo sharing (Phases 126–127):** pass `visibility: 'public'` in the
252+
`POST /api/v1/remote/index` request body to flag a repo as public (default
253+
`'private'`). A non-owner authenticated caller indexing an existing public
254+
repo is auto-granted `read` access; registering a *brand-new* public repo
255+
requires `auth.allowPublicAutoIndex` / `GITSEMA_PUBLIC_AUTO_INDEX` (default
256+
off) unless the caller is an operator. Non-owner re-index triggers on a
257+
public repo are throttled to one per `auth.minReindexIntervalSeconds` /
258+
`GITSEMA_MIN_REINDEX_INTERVAL_SECONDS` (default 300s, returns `429` +
259+
`Retry-After`). See
260+
[`docs/features.md`](docs/features.md#public-repo-sharing-phases-126127) for details.
261+
250262
> **Deploying the server?** See the [deployment guide](docs/deploy.md) for Docker /
251263
> docker-compose, systemd, the Postgres + Qdrant backends, key security, backups,
252264
> and per-repo-size tuning. The repo also ships `docker-compose.yml` (Ollama

docs/PLAN.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4862,10 +4862,53 @@ that deprecated it, and its removal status.
48624862
| **126** | §5 Phase 1 | Visibility flag + attach-as-reader | `repos.visibility` (`'private'\|'public'`, default `'private'`) + `ownerUserId` columns; `gitsema repos visibility <repoId> public\|private` CLI (owner/superadmin only); registration-flow change in `src/server/routes/remote.ts` auto-issuing a `repo_grants` reader row when a second user attaches to an existing public repo's shared index. |
48634863
| **127** | §5 Phase 2 | First-index gate + refresh throttle | `auth.allowPublicAutoIndex`/`GITSEMA_PUBLIC_AUTO_INDEX` config gate (default `false`) restricting who may register a brand-new public-flagged repo; `auth.minReindexIntervalSeconds` per-`(user, repoId)` refresh throttle returning `429`/`Retry-After`. No hard dependency on Phase 126 beyond the `visibility` column existing. |
48644864

4865-
**Status:** not started — draft design, scheduled here per `/phase-plan`.
4865+
**Status:** ✅ complete *(completed vNEXT)*. Implemented in
4866+
`src/server/routes/remote.ts` (registration-flow gate/throttle/attach-as-reader
4867+
logic), `src/core/indexing/repoRegistry.ts` (`visibility`/`ownerUserId` columns,
4868+
`setRepoVisibility`, `isPublicAutoIndexAllowed`, `getMinReindexIntervalSeconds`),
4869+
and `src/cli/commands/repos.ts` (`gitsema repos visibility <repoId> public|private`).
48664870
Explicitly out of scope (per the design doc §6): cross-repo blob-level dedup
48674871
for forks with different URLs ("shape 2") remains undesigned and unscheduled.
48684872

4873+
Deviations from the design doc, discovered during implementation:
4874+
- **Two independent SQLite databases in a `gitsema tools serve` deployment.**
4875+
`getActiveSession()` (cwd-relative `.gitsema/index.db`, used by the entire
4876+
Phase 122-125 auth/orgs/grants system and by `authMiddleware`'s
4877+
`req.userId` resolution) and `getRegistrySession()`
4878+
(`${GITSEMA_DATA_DIR}/registry.db`, cwd-independent, used for persisted
4879+
repo clone/index-path bookkeeping since Phase 41) are two separate DB
4880+
files, each running the full schema with its own independent `users`/
4881+
`repos`/`repo_grants` tables and per-file FK enforcement. The design doc
4882+
speaks of "the `repos` table" and "the `users` table" as if unified; this
4883+
split predates Phase 126 and was not anticipated by the spec. Resolution:
4884+
`registry.db` keeps its original sole purpose (clone/index-path
4885+
bookkeeping) and never stores `ownerUserId`; the active DB becomes the
4886+
canonical store for `visibility`/`ownerUserId`/`repo_grants`, with
4887+
`runIndexJob` performing a dual-write to mirror the repo's `id`/`name`/
4888+
`url`/`normalizedUrl`/`clonePath`/`dbPath`/`visibility` row into both DBs
4889+
after each successful persisted index, and `ownerUserId` written only to
4890+
the active DB's copy. All visibility/ownership/grant reads in the route
4891+
handler resolve against the active DB's mirrored row, never `registry.db`'s.
4892+
This is a server-deployment-internal data-plumbing detail with no surfaced
4893+
CLI/API change; the broader question of whether the active session's
4894+
cwd-relative default is the right long-term identity-store location for
4895+
`gitsema tools serve` specifically (vs. tying it to `GITSEMA_DATA_DIR`) is
4896+
deferred — fixing it would be a breaking change to already-shipped Phase
4897+
122-125 behavior, well beyond this track's scope.
4898+
- Grant role naming uses the existing `'read'|'write'|'owner'` enum (Phase
4899+
123) rather than the design doc's `'reader'` wording — no new role was
4900+
introduced.
4901+
- "Superadmin" in the design doc's first-index gate is resolved via the
4902+
existing "operator" trust boundary (`req.userId === undefined` — local
4903+
CLI/global-key/no-auth-required callers), consistent with the Phase
4904+
122-125 precedent that operator-equivalent access is a stronger trust
4905+
tier than any network role, rather than introducing a new superadmin flag.
4906+
- Flipping a repo back to `private` does not auto-revoke previously
4907+
auto-issued `repo_grants` rows (per the design doc's own open question in
4908+
§7) — they remain until explicitly revoked via `gitsema repos revoke`.
4909+
- `repo_grants.source` (`'auto-public'` vs. manual) was added to distinguish
4910+
attach-as-reader auto-grants from explicit `gitsema repos grant` grants.
4911+
48694912
---
48704913

48714914
## Superadmin-Locked Model Set Track (Phases 128–130)

docs/feature-ideas.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This document tracks upcoming feature ideas that are **not yet in active development** (not in `PLAN.md`) and haven't been **fully designed** (no design file). It's a staging area for "what now?" questions and medium-term product direction.
44

5-
**Last updated:** 2026-06-23 (added audit log coverage enforcement idea, found during the Phase 125 `/simplify` review; refined public-repo sharing's access-control half into `docs/public-repo-sharing-plan.md` and the superadmin-locked model set idea into `docs/locked-model-set-plan.md`; kept cross-repo blob dedup as an open idea)
5+
**Last updated:** 2026-06-23 (added the public-repo-sharing throttle/policy-extraction idea, found during the Phase 126/127 `/simplify` review; added audit log coverage enforcement idea, found during the Phase 125 `/simplify` review; refined public-repo sharing's access-control half into `docs/public-repo-sharing-plan.md` and the superadmin-locked model set idea into `docs/locked-model-set-plan.md`; kept cross-repo blob dedup as an open idea)
66
**Audience:** Developers considering next phases; product planning
77

88
> **Note:** As of this update, the LSP/MCP remote-delegation foundation this
@@ -473,6 +473,51 @@ shapes (none chosen yet):
473473

474474
---
475475

476+
## Public Repo Sharing: Throttle/Rate-Limit Unification & Policy Extraction
477+
478+
### Problem
479+
- Found during the `/simplify` review of Phase 126/127 (public-repo-sharing).
480+
`checkAndRecordReindexThrottle()` (`src/server/routes/remote.ts`) is a
481+
bespoke per-`(repoId, userId)` cooldown map, separate from the generic
482+
abuse-prevention rate limiter in `src/server/middleware/rateLimiter.ts`
483+
(`express-rate-limit`, keyed by Bearer token/IP, fixed window). They serve
484+
different concerns today — one is a global RPM cap, the other a
485+
business-rule re-index cooldown — but having two independent
486+
rate-limiting mechanisms in the same route module is worth revisiting if
487+
a third throttle-shaped requirement shows up.
488+
- The same review flagged that the 2c/2d/2e public-repo gate/throttle/grant
489+
logic in the `POST /api/v1/remote/index` handler (first-index gate,
490+
refresh throttle, attach-as-reader auto-grant) could be extracted into a
491+
single `applyPublicRepoPolicy()` function for readability, but that was
492+
judged too large a restructuring for a cleanup pass on already-shipped
493+
code.
494+
495+
### Intended Behavior
496+
No design committed yet. Two independent, optional follow-ups:
497+
- If a third per-key throttle need appears, consider whether a shared
498+
generic "keyed cooldown" utility (used by both `rateLimiter.ts` and
499+
`checkAndRecordReindexThrottle`) is worth building, vs. keeping them
500+
separate as distinct concerns.
501+
- Extract the public-repo gate/throttle/grant sequence in
502+
`src/server/routes/remote.ts` into a named `applyPublicRepoPolicy()` (or
503+
similar) function once it grows another condition or gets touched again,
504+
rather than as a standalone refactor now.
505+
506+
### Design Gaps
507+
- [ ] Whether a generic keyed-cooldown abstraction is worth the indirection
508+
given only one current caller (`checkAndRecordReindexThrottle`).
509+
- [ ] Where the line is for "policy extraction" — at what point does the
510+
2c/2d/2e sequence justify its own function vs. staying inline.
511+
512+
### Effort Estimate
513+
- Small either way — both are isolated, mechanical refactors with existing
514+
test coverage to verify against.
515+
516+
### Prerequisites
517+
- None — both are optional cleanups on already-shipped Phase 126/127 code.
518+
519+
---
520+
476521
## Related Issues & Documents
477522

478523
- **Parity tracking:** See `docs/parity.md` for tool availability across interfaces

docs/features.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,46 @@ audit events — the equivalent operator-only CLI-direct paths (`gitsema repos g
369369
v1, since those paths already require local DB access, a stronger trust boundary than
370370
the network surface this audit trail is primarily meant to cover.
371371

372+
### Public repo sharing (Phases 126–127)
373+
374+
Registration-flow extension layered on top of Phases 122-123's `repo_grants`
375+
model, letting a repo owner opt their persisted repo into shared read access
376+
without minting individual grants by hand. Three axes:
377+
- **Visibility flag**`repos.visibility` (`'private'` default, `'public'`),
378+
set via `gitsema repos visibility <repoId> public|private` (operator-only —
379+
no network auth boundary on this command). `repos.owner_user_id` records the
380+
first user (or `null` for an operator/no-auth caller) whose registration
381+
request created the repo; first-claimer semantics are preserved across
382+
re-indexes — later registration requests never overwrite it.
383+
- **Attach-as-reader auto-grant** — when an authenticated, non-owner caller
384+
triggers `POST /api/v1/remote/index` against an *existing* `public` repo
385+
they don't already have a grant on, a `read`-role `repo_grants` row is
386+
auto-issued for them with `source: 'auto-public'` (distinguishing it from a
387+
manually issued grant). A caller who already holds a higher role
388+
(`write`/`owner`) is never downgraded.
389+
- **Trigger rights** — registering a *brand-new* repo as `public` requires
390+
`auth.allowPublicAutoIndex` / `GITSEMA_PUBLIC_AUTO_INDEX` (default `false`)
391+
to be enabled, unless the caller is an operator (no `req.userId` — local
392+
CLI/global-key/no-auth-required request, the same stronger-trust-tier
393+
precedent established in Phases 122-125). Once a public repo exists,
394+
non-owner re-index triggers are throttled to at most one per
395+
`auth.minReindexIntervalSeconds` / `GITSEMA_MIN_REINDEX_INTERVAL_SECONDS`
396+
(default 300s) per `(user, repo)` pair, returning `429` + `Retry-After`; the
397+
repo's owner is never throttled.
398+
399+
**Implementation note — two independent databases.** A `gitsema tools serve`
400+
deployment has two separate SQLite files: the cwd-relative active session
401+
(`.gitsema/index.db`, the canonical store for the entire Phase 122-125 auth/
402+
orgs/grants system, resolved by `authMiddleware`) and the registry session
403+
(`${GITSEMA_DATA_DIR}/registry.db`, cwd-independent, tracking persisted-repo
404+
clone/index paths since Phase 41). Both run the full schema with independent
405+
per-file FK enforcement, so an `owner_user_id` valid in one is not
406+
automatically valid in the other. `registry.db` keeps its original sole
407+
purpose and never stores `owner_user_id`; the active DB is the canonical
408+
store for `visibility`/`owner_user_id`/`repo_grants`, kept in sync by a
409+
dual-write in `runIndexJob` after each successful persisted index. See
410+
`docs/PLAN.md`'s Phase 126/127 entry for the full deviation note.
411+
372412
### Persistent server-side repo storage
373413

374414
`POST /api/v1/remote/index` **persists** the clone + index by default (`persist: true`),

docs/parity.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ This table shows which tools/commands are available in which interface. A checkm
136136
| `auth` (login/logout/whoami/token */create-user) |||||||||
137137
| `orgs` (create/list/members */`users` create/list) |||||||||
138138
| `repos grant/grants/revoke/move-to-org` |||||||||
139+
| `repos visibility` |||||||||
139140
| `auth sso link/unlink/list` |||||||||
140141
| `audit log` |||||||||
141142
| `quickstart` / `setup` |||||||||

src/cli/commands/repos.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Command } from 'commander'
22
import { createHash, randomBytes } from 'node:crypto'
33
import { rmSync } from 'node:fs'
44
import { getActiveSession, getRawDb } from '../../core/db/sqlite.js'
5-
import { addRepo, listRepos, multiRepoSearch, getRegistrySession, getRepo, getRepoDir, removeRepo } from '../../core/indexing/repoRegistry.js'
5+
import { addRepo, listRepos, multiRepoSearch, getRegistrySession, getRepo, getRepoDir, removeRepo, setRepoVisibility } from '../../core/indexing/repoRegistry.js'
66
import { buildProvider } from '../../core/embedding/providerFactory.js'
77
import { embedQuery } from '../../core/embedding/embedQuery.js'
88
import { parsePositiveInt } from '../../utils/parse.js'
@@ -263,6 +263,24 @@ export function reposCommand(): Command {
263263
console.log(`Revoked ${revoked} grant(s) for '${username}' on repo '${repoId}'.`)
264264
})
265265

266+
cmd
267+
.command('visibility <repo-id> <state>')
268+
.description('Set a repo\'s visibility to public or private (Phase 126) — operator-only, no network auth boundary')
269+
.action((repoId: string, state: string) => {
270+
if (state !== 'public' && state !== 'private') {
271+
console.error("Error: state must be 'public' or 'private'")
272+
process.exit(1)
273+
}
274+
const session = getRegistrySession()
275+
const repo = getRepo(session, repoId)
276+
if (!repo) {
277+
console.error(`Error: repo '${repoId}' not found in registry`)
278+
process.exit(1)
279+
}
280+
setRepoVisibility(session, repoId, state)
281+
console.log(`Repo '${repoId}' visibility set to '${state}'.`)
282+
})
283+
266284
cmd
267285
.command('move-to-org <repo-id> <org>')
268286
.description('Move a repo to a different org; existing grants survive the move (Phase 123)')

0 commit comments

Comments
 (0)