diff --git a/apps/api/src/lib/list-repos.ts b/apps/api/src/lib/list-repos.ts index e9f09aa..f1d22a5 100644 --- a/apps/api/src/lib/list-repos.ts +++ b/apps/api/src/lib/list-repos.ts @@ -22,7 +22,7 @@ type GithubFlavoredRepo = { description: string | null private: boolean owner: { login: string } - permissions?: { admin?: boolean; push?: boolean } + permissions?: { admin?: boolean; maintain?: boolean } archived?: boolean } @@ -51,7 +51,8 @@ async function listGithubFlavored( if (!Array.isArray(data) || data.length === 0) break for (const r of data) { if (r.archived) continue - const writable = r.permissions?.admin || r.permissions?.push + // ponytail: mirror checkOwnership — maintain+ submits. Gitea lacks `maintain`, admin is its ceiling. + const writable = r.permissions?.admin || r.permissions?.maintain if (r.permissions && !writable) continue repos.push({ providerInstanceId: instanceId, diff --git a/apps/api/src/lib/providers.ts b/apps/api/src/lib/providers.ts index cf5dc8a..b7bc769 100644 --- a/apps/api/src/lib/providers.ts +++ b/apps/api/src/lib/providers.ts @@ -38,7 +38,6 @@ async function checkGithubFlavored( apiBase: string, accessToken: string, ref: RepoRef, - username: string, userAgent: string | null, ): Promise { const headers: Record = { Authorization: `Bearer ${accessToken}` } @@ -46,40 +45,41 @@ async function checkGithubFlavored( const res = await fetch(`${apiBase}/repos/${ref.owner}/${ref.repo}`, { headers }) if (res.status === 404) return { owned: false, reason: 'Repo not found' } if (!res.ok) return { owned: false, reason: `API error: ${res.status}` } - const data = (await res.json()) as { owner?: { login?: string } } - if (data.owner?.login?.toLowerCase() !== username.toLowerCase()) { - return { owned: false, reason: 'Repo does not belong to authenticated user' } - } - return { owned: true } + const data = (await res.json()) as { permissions?: { admin?: boolean; maintain?: boolean } } + // ponytail: maintain>=submit. GitHub owners have admin; Gitea has no `maintain` + // field so admin is its ceiling equivalent. Drop `username` param when GitLab + // path below is the last owner-based check to go. + if (data.permissions?.maintain || data.permissions?.admin) return { owned: true } + return { owned: false, reason: 'Requires maintain permission on the repo' } } -async function checkGitlab( - baseUrl: string, - accessToken: string, - ref: RepoRef, - username: string, -): Promise { +async function checkGitlab(baseUrl: string, accessToken: string, ref: RepoRef): Promise { const projectId = encodeURIComponent(ref.fullName) const res = await fetch(`${baseUrl}/api/v4/projects/${projectId}`, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (res.status === 404) return { owned: false, reason: 'Repo not found' } if (!res.ok) return { owned: false, reason: `GitLab API error: ${res.status}` } - const data = (await res.json()) as { namespace?: { path?: string; kind?: string } } - if (data.namespace?.kind === 'user' && data.namespace.path?.toLowerCase() === username.toLowerCase()) { - return { owned: true } + const data = (await res.json()) as { + permissions?: { project_access?: { access_level?: number }; group_access?: { access_level?: number } } } - return { owned: false, reason: 'Repo does not belong to authenticated user' } + // GitLab access levels: Maintainer=40, Owner=50. Group access covers inherited perms. + const level = Math.max( + data.permissions?.project_access?.access_level ?? 0, + data.permissions?.group_access?.access_level ?? 0, + ) + if (level >= 40) return { owned: true } + return { owned: false, reason: 'Requires maintainer access on the project' } } -export async function checkOwnership(accessToken: string, ref: RepoRef, username: string): Promise { +export async function checkOwnership(accessToken: string, ref: RepoRef): Promise { const { instance } = ref if (instance.kind === 'github') { const apiBase = instance.baseUrl === 'https://github.com' ? 'https://api.github.com' : `${instance.baseUrl}/api/v3` - return checkGithubFlavored(apiBase, accessToken, ref, username, 'tabularium/1.0') + return checkGithubFlavored(apiBase, accessToken, ref, 'tabularium/1.0') } if (instance.kind === 'gitea') { - return checkGithubFlavored(`${instance.baseUrl}/api/v1`, accessToken, ref, username, null) + return checkGithubFlavored(`${instance.baseUrl}/api/v1`, accessToken, ref, null) } - return checkGitlab(instance.baseUrl, accessToken, ref, username) + return checkGitlab(instance.baseUrl, accessToken, ref) } diff --git a/apps/api/src/routes/api/submit/oauth.ts b/apps/api/src/routes/api/submit/oauth.ts index 7e90080..919d0f8 100644 --- a/apps/api/src/routes/api/submit/oauth.ts +++ b/apps/api/src/routes/api/submit/oauth.ts @@ -64,7 +64,7 @@ export default new Elysia() } throw e } - const ownership = await checkOwnership(accessToken, ref, identity.username) + const ownership = await checkOwnership(accessToken, ref) if (!ownership.owned) { set.status = 403 diff --git a/apps/api/src/routes/api/submit/preview.ts b/apps/api/src/routes/api/submit/preview.ts index fafb232..35c56b6 100644 --- a/apps/api/src/routes/api/submit/preview.ts +++ b/apps/api/src/routes/api/submit/preview.ts @@ -80,7 +80,7 @@ export default new Elysia() } throw e } - const ownership = await checkOwnership(accessToken, ref, identity.username) + const ownership = await checkOwnership(accessToken, ref) if (!ownership.owned) { set.status = 403 return { ok: false as const, error: ownership.reason } diff --git a/apps/api/tests/routes/submit.test.ts b/apps/api/tests/routes/submit.test.ts index b14f263..f694eb2 100644 --- a/apps/api/tests/routes/submit.test.ts +++ b/apps/api/tests/routes/submit.test.ts @@ -31,7 +31,9 @@ describe('POST /api/submit/oauth', () => { const fetchSpy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => { if (String(url).includes('api.github.com/repos')) { - return new Response(JSON.stringify({ owner: { login: 'alice' } }), { status: 200 }) + return new Response(JSON.stringify({ owner: { login: 'alice' }, permissions: { maintain: true } }), { + status: 200, + }) } return new Response('Not found', { status: 404 }) }) as unknown as typeof fetch) @@ -81,7 +83,9 @@ describe('POST /api/submit/oauth', () => { const fetchSpy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => { if (String(url).includes('api.github.com/repos')) { - return new Response(JSON.stringify({ owner: { login: 'alice' } }), { status: 200 }) + return new Response(JSON.stringify({ owner: { login: 'alice' }, permissions: { maintain: true } }), { + status: 200, + }) } return new Response('Not found', { status: 404 }) }) as unknown as typeof fetch) @@ -146,7 +150,9 @@ describe('POST /api/submit/oauth', () => { const fetchSpy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => { const u = String(url) if (u.includes('api.github.com/repos') && u.endsWith('/repos/alice/my-plugin')) { - return new Response(JSON.stringify({ owner: { login: 'alice' } }), { status: 200 }) + return new Response(JSON.stringify({ owner: { login: 'alice' }, permissions: { maintain: true } }), { + status: 200, + }) } if (u.includes('/releases/latest')) { return new Response(JSON.stringify({ tag_name: 'v0.1.0', assets: [] }), { status: 200 }) diff --git a/deploy/helm/tabularium/Chart.yaml b/deploy/helm/tabularium/Chart.yaml index d672873..7777153 100644 --- a/deploy/helm/tabularium/Chart.yaml +++ b/deploy/helm/tabularium/Chart.yaml @@ -4,7 +4,7 @@ description: Self-hosted plugin registry (Tabularium). Bun + Elysia + SvelteKit type: application home: https://tabularium.wiki sources: - - https://codeberg.org/NewtTheWolf/Tabularium + - https://github.com/TabularisDB/tabularium keywords: - registry - plugins @@ -13,12 +13,12 @@ keywords: - sveltekit maintainers: - name: NewtTheWolf - url: https://codeberg.org/NewtTheWolf + url: https://github.com/TabularisDB icon: https://tabularium.wiki/assets/icon.png # Chart SemVer — bump when templates change. -version: 0.11.1 +version: 0.13.0 # Tabularium app version this chart was tested against — keep in sync with # the image tag default in values.yaml. -appVersion: "0.11.1" +appVersion: "0.13.0" diff --git a/deploy/helm/tabularium/values.yaml b/deploy/helm/tabularium/values.yaml index 1fc9f0a..4be1f7e 100644 --- a/deploy/helm/tabularium/values.yaml +++ b/deploy/helm/tabularium/values.yaml @@ -3,7 +3,7 @@ # ---- Image ----------------------------------------------------------------- image: - repository: codeberg.org/tabularium/tabularium + repository: ghcr.io/tabularisdb/tabularium # If unset, falls back to .Chart.AppVersion. tag: "" pullPolicy: IfNotPresent diff --git a/tabularis-integration-spec.md b/tabularis-integration-spec.md new file mode 100644 index 0000000..37490b9 --- /dev/null +++ b/tabularis-integration-spec.md @@ -0,0 +1,155 @@ +# Tabularis ↔ Tabularium — integration spec (for the client-side agent) + +Goal: let Tabularis browse plugins from a Tabularium registry and **install +them with automatic integrity verification**. The registry never hosts the +binaries — it hosts *signed hashes* of the forge (GitHub) release assets. The +client downloads the asset from the forge and proves it matches what the plugin +author published. + +Base URL of the public instance: `https://registry.tabularis.dev` +(self-hosters set their own; read it back from the signed payload's `registry` +field, never hardcode). + +--- + +## 1. The `.tabularium` manifest + +Each plugin is a repo with a `.tabularium` file (JSON/YAML). At every GitHub +release the author **attaches `.tabularium` as a release asset**; the registry +ingests it, validates it, hashes every other release asset, and stores the raw +manifest bytes verbatim. Relevant fields: + +| field | req | meaning | +|-------|-----|---------| +| `name` | ✓ | slug — `^[a-z][a-z0-9-]*$`, ≤64 | +| `version` | ✓ | semver, **no** `v` prefix; must equal the release tag stripped of `v` | +| `kind` | — | plugin kind (drives which extension fields apply) | +| `description`,`category`,`tags`,`license`,`icon` | — | catalogue metadata | +| `readmes:` | — | per-locale README paths | +| `assets` | — | per-platform download entries (`universal` or `os/arch` keyed) | + +The client does **not** parse `.tabularium` off the forge itself — it gets the +canonical bytes (`manifest_raw`) from the registry, already hash-pinned by the +signature (see §3). + +--- + +## 2. Endpoints the client calls + +| method | path | returns | +|--------|------|---------| +| GET | `/.well-known/registry-key.json` | JWKS `{ keys: [current, previous?] }` — Ed25519 public keys, `application/jwk-set+json`, cache 300s | +| GET | `/api/plugins` | catalogue (browse/search) | +| GET | `/api/plugins/{slug}` | plugin detail | +| GET | `/api/plugins/{slug}/latest?os=&arch=` | resolves the asset for a platform → `{ download_url, sha256, platforms{…} }`. `?redirect=1` → 302 to `download_url`. UA-sniffed if os/arch omitted; falls back to `universal`. | +| GET | `/api/plugins/{slug}/releases/{version}/integrity` | **the signed integrity doc** — see §3 | + +--- + +## 3. The integrity document (the important one) + +`GET /api/plugins/{slug}/releases/{version}/integrity` → + +```jsonc +{ + "slug": "my-plugin", + "version": "1.2.0", + "jws": "", // present on post-ingest releases + "assets": [ + { "name": "driver-linux-x64.tar.gz", "sha256": "…", "size": 12345, + "attestation_bundle": { /* sigstore provenance, or null */ } } + ], + "manifest_raw": "" +} +``` + +The **`jws`** is a JWS Compact Serialization (jose `CompactSign`), protected +header `{ "alg": "EdDSA", "kid": "" }`. Its payload is the +**canonicalized** (JCS) JSON of: + +```jsonc +{ + "v": 1, + "kid": "", + "issued_at": 1730000000, + "registry": "https://registry.tabularis.dev", + "plugin_slug": "my-plugin", + "release_version": "1.2.0", + "manifest_sha256": "", + "assets": [ { "name": "…", "sha256": "…", "size": 12345 } ] +} +``` + +Everything the client trusts must come from **inside the verified JWS payload**, +not from the surrounding (unsigned) JSON envelope. The top-level `assets`/ +`manifest_raw` are conveniences; the signed copies are authoritative. + +--- + +## 4. Verification flow (what the client agent implements) + +``` +1. Fetch JWKS → keys[] (match by `kid`; keep `previous` so rotation doesn't break installs) +2. GET integrity for {slug, version} +3. Verify jws with the JWKS key whose kid matches the protected header + → yields the canonical payload bytes → parse to `payload` + (jose: `compactVerify(jws, key)`; or use verifyRegistrySignature() from @tabularium/manifest + with the payload bytes + raw signature + public JWK) +4. Guard the payload: + payload.plugin_slug === slug + payload.release_version === version + payload.registry === the registry you fetched from + (reject on any mismatch — a valid signature over the WRONG release is still an attack) +5. Manifest pin (if manifest_raw != null): + sha256(manifest_raw) === payload.manifest_sha256 → then parse manifest from manifest_raw +6. For each asset to install: + a. resolve download_url via /latest (or the release asset list), match by asset `name` + b. look up expected sha256 = payload.assets[name].sha256 ← SIGNED value only + c. stream the download through verifyAssetHash(stream, expectedSha256) + → { ok, sha256, size }; INSTALL ONLY IF ok === true + d. (optional) verify attestation_bundle against sigstore for build provenance +7. Key rotation: if kid isn't in the current JWKS, re-fetch JWKS once (cache may be stale); + the previous key stays published so in-flight installs still verify. +8. Legacy fallback: if `jws` is absent (old release, no per-asset rows), the integrity + doc degrades to `{ assets: { : { url, size, sha256 } } }` UNSIGNED. + Treat unsigned installs as unverified — warn or refuse per your trust policy. +``` + +### Ready-made primitives — `@tabularium/manifest` + +Pure Web Crypto, no node/Bun deps (runs in browser/Deno/Node≥19/Bun): + +```ts +import { verifyAssetHash, verifyRegistrySignature, parseManifest, validateManifest } + from '@tabularium/manifest' + +// asset hash — streams, constant-time compare +const { ok, sha256, size } = await verifyAssetHash(response.body, expectedSha256Hex) + +// registry signature (lower-level; payloadBytes = canonical JSON bytes, signature = raw 64B) +const good = await verifyRegistrySignature({ payloadBytes, signature, publicKeyJwk }) +``` + +For the JWS itself the simplest path is jose `compactVerify(jws, publicKey)` — +it returns the canonical payload bytes, which you then `JSON.parse`. +`verifyRegistrySignature` is the dependency-free alternative if you split the +JWS into `payloadBytes` + `signature` yourself. + +--- + +## 5. Threat model recap (why each step exists) + +- **Registry is compromised / MITM** → the JWS is signed by the author-facing + registry key; a tampered registry can't forge a valid Ed25519 signature. +- **Forge asset swapped after publish** → per-asset SHA-256 in the signed + payload won't match; `verifyAssetHash` fails, install refused. +- **Wrong-release replay** (valid sig, different plugin/version) → §4 step 4 + guards slug/version/registry. +- **Manifest tampering** → `manifest_sha256` is inside the signed payload and + pins `manifest_raw`, so no forge round-trip is needed or trusted. +- **Key rotation** → JWKS serves current + previous; match by `kid`. +- **Build provenance** → optional `attestation_bundle` (GitHub + `attest-build-provenance`, sigstore) for who/what built the asset. + +Net: the client gets exactly what the author published, or the install fails +closed. No trust in the network path or the registry host beyond its signing key.