Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/lib/mcp/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const groups: Record<string, string[]> = {
'getI18nConfig',
'listProviders',
],
'account:read': ['getMe', 'listTransfers', 'listSubmittableRepos'],
'account:read': ['getMe', 'listTransfers', 'listSubmittableRepos', 'listRepoOwners'],
'plugins:write': [
'publishRelease',
'submitPreview',
Expand All @@ -38,7 +38,7 @@ const groups: Record<string, string[]> = {
'authorRehashRelease',
'authorYankRelease',
],
'requests:write': ['createRequest', 'toggleRequestClaim', 'toggleUpvote', 'scaffoldRequest'],
'requests:write': ['createRequest', 'toggleRequestClaim', 'toggleUpvote', 'scaffoldRequest', 'linkRequestRepo'],
}
// Explicit allowlist: new REST endpoints are never exposed automatically.
const adminOperations = new Set([
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/lib/scaffold/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ export async function grantedScopes(
}
}

export type RepoOwner = { login: string; type: 'user' | 'org'; avatarUrl: string | null }

/** The user's own account, then every org they belong to (needs `read:org` for private memberships). */
export async function listGithubOwners(instance: ProviderInstance, token: string): Promise<RepoOwner[]> {
const gh = client(instance, token)
type Account = { login: string; avatar_url?: string }
const { data: me } = await gh<Account>('GET', '/user')
const { data: orgs } = await gh<Account[]>('GET', '/user/orgs?per_page=100')
return [
{ login: me.login, type: 'user' as const, avatarUrl: me.avatar_url ?? null },
...orgs
.map((o) => ({ login: o.login, type: 'org' as const, avatarUrl: o.avatar_url ?? null }))
.sort((a, b) => a.login.localeCompare(b.login)),
]
}

export type CreatedRepo = { owner: string; name: string; fullName: string; htmlUrl: string; defaultBranch: string }

/**
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/lib/webhooks/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const WEBHOOK_EVENTS = [
'request.claimed',
'request.unclaimed',
'request.scaffolded',
'request.repo_linked',
'request.fulfilled',
] as const

Expand Down Expand Up @@ -53,6 +54,7 @@ const META: Record<WebhookEvent, { title: string; color: number }> = {
'request.claimed': { title: 'Plugin request claimed', color: 0x06b6d4 },
'request.unclaimed': { title: 'Plugin request claim released', color: 0x64748b },
'request.scaffolded': { title: 'Plugin repository created', color: 0x8b5cf6 },
'request.repo_linked': { title: 'Plugin repository linked', color: 0x8b5cf6 },
'request.fulfilled': { title: 'Plugin request fulfilled', color: 0x22c55e },
}

Expand Down Expand Up @@ -293,7 +295,10 @@ export function sampleContext(type: WebhookEvent): EventContext {
kind: null,
status: SAMPLE_REQUEST_STATUS[type] ?? 'approved',
claims: type === 'request.created' ? 0 : 1,
repoUrl: type === 'request.scaffolded' ? 'https://github.com/janedoe/example-plugin' : null,
repoUrl:
type === 'request.scaffolded' || type === 'request.repo_linked'
? 'https://github.com/janedoe/example-plugin'
: null,
url,
}
return ctx
Expand Down
124 changes: 124 additions & 0 deletions apps/api/src/routes/api/requests/[id]/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Elysia, t } from 'elysia'
import { and, eq } from 'drizzle-orm'
import { authMiddleware } from '$middleware/auth'
import { rateLimit } from '$middleware/rate-limit'
import { db } from '$db'
import { pluginRequestClaims } from '$db/schema'
import { hasClaimedRequest } from '$lib/claims'
import { getValidAccessToken, OAuthExpiredError } from '$lib/oauth-tokens'
import { checkOwnership, parseRepoUrl } from '$lib/providers'
import { claimerSchema, loadClaimers } from '$lib/request-view'
import { emitEvent, webBaseUrl } from '$lib/webhooks/events'

const errorSchema = t.Object({ error: t.String(), reauthUrl: t.Optional(t.String()) })

export default new Elysia()
.use(authMiddleware)
.use(rateLimit({ bucket: 'request-repo', limit: 20, windowSeconds: 3600 }))
.post(
'/',
async ({ params, body, user, set }) => {
const request = await db.query.pluginRequests.findFirst({ where: { id: params.id } })
if (!request) {
set.status = 404
return { error: 'Request not found' }
}
if (!(await hasClaimedRequest(request.id, user.sub))) {
set.status = 409
return { error: 'Claim this request before linking a repository.' }
}
const where = and(eq(pluginRequestClaims.requestId, request.id), eq(pluginRequestClaims.userId, user.sub))

// Unlinking needs no provider round-trip.
if (body.repoUrl === null) {
await db.update(pluginRequestClaims).set({ repoUrl: null, scaffoldedAt: null }).where(where)
const claimers = (await loadClaimers([request.id])).get(request.id) ?? []
return { repoUrl: null, fullName: null, submitUrl: null, claimers }
}

if (request.status !== 'approved') {
set.status = 409
return { error: 'Only approved requests can get a repository.' }
}
const ref = parseRepoUrl(body.repoUrl)
if (!ref) {
set.status = 422
return { error: 'Not a repository URL on a provider this registry knows.' }
}

const identity = (await db.query.identities.findMany({ where: { userId: user.sub } })).find(
(i) => i.providerInstanceId === ref.instance.id,
)
if (!identity?.accessToken) {
set.status = 412
return { error: `Link a ${ref.instance.displayName} account to prove you maintain ${ref.fullName}.` }
}
let token: string
try {
token = await getValidAccessToken(identity, ref.instance)
} catch (err) {
if (!(err instanceof OAuthExpiredError)) throw err
set.status = 401
return {
error: `Your ${ref.instance.displayName} session expired.`,
reauthUrl: `${webBaseUrl()}/auth/${encodeURIComponent(ref.instance.id)}?return_to=${encodeURIComponent(body.returnTo ?? `${webBaseUrl()}/requests`)}`,
}
}
// Same bar as submitting a plugin: maintain (or admin) on the repository.
const ownership = await checkOwnership(token, ref)
if (!ownership.owned) {
set.status = 403
return { error: `${ref.fullName}: ${ownership.reason}` }
}

const repoUrl = `${ref.instance.baseUrl}/${ref.fullName}`
await db.update(pluginRequestClaims).set({ repoUrl, scaffoldedAt: null }).where(where)
const claimers = (await loadClaimers([request.id])).get(request.id) ?? []
await emitEvent('request.repo_linked', {
actor: { id: user.sub, name: user.username ?? null },
request: { ...request, claims: claimers.length, repoUrl },
})
return {
repoUrl,
fullName: ref.fullName,
submitUrl: `${webBaseUrl()}/submit?repo=${encodeURIComponent(repoUrl)}`,
claimers,
}
},
{
detail: {
tags: ['Requests'],
summary: 'Link an existing repository to your claim',
description:
"Records a repository you already maintain as the work for your claim on an approved request, instead of scaffolding a new one. The repository must be on a provider you have linked, with maintain or admin permission (the same check as submitting a plugin). Pass `repoUrl: null` to unlink. The request still ships only when a plugin with the request's slug is listed.",
operationId: 'linkRequestRepo',
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
},
params: t.Object({ id: t.String() }),
body: t.Object({
repoUrl: t.Nullable(
t.String({ maxLength: 500, description: 'Repository URL, e.g. https://github.com/acme/my-plugin.' }),
),
returnTo: t.Optional(
t.String({
maxLength: 512,
description: 'Where to come back after re-authorizing. Must be a trusted origin.',
}),
),
}),
response: {
200: t.Object({
repoUrl: t.Nullable(t.String()),
fullName: t.Nullable(t.String()),
submitUrl: t.Nullable(t.String()),
claimers: t.Array(claimerSchema),
}),
401: errorSchema,
403: errorSchema,
404: errorSchema,
409: errorSchema,
412: errorSchema,
422: errorSchema,
},
},
)
53 changes: 53 additions & 0 deletions apps/api/src/routes/api/submit/owners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Elysia, t } from 'elysia'
import { authMiddleware } from '$middleware/auth'
import { db } from '$db'
import { getValidAccessToken, OAuthExpiredError } from '$lib/oauth-tokens'
import { getInstance } from '$lib/provider-instance'
import { listGithubOwners, GithubScaffoldError } from '$lib/scaffold/github'

const ownerSchema = t.Object({
login: t.String(),
type: t.Union([t.Literal('user'), t.Literal('org')]),
avatarUrl: t.Nullable(t.String()),
})

export default new Elysia().use(authMiddleware).get(
'/',
async ({ user, query, set }) => {
const candidates = await db.query.identities.findMany({ where: { userId: user.sub } })
const identity = candidates.find(
(i) => (!query.identityId || i.id === query.identityId) && getInstance(i.providerInstanceId)?.kind === 'github',
)
const instance = identity ? getInstance(identity.providerInstanceId) : undefined
if (!identity || !instance) {
set.status = 412
return { error: 'Listing repository owners needs a linked GitHub account.' }
}
try {
const token = await getValidAccessToken(identity, instance)
return { identityId: identity.id, owners: await listGithubOwners(instance, token) }
} catch (err) {
if (err instanceof OAuthExpiredError || (err instanceof GithubScaffoldError && err.status === 401)) {
set.status = 401
return { error: `Your ${instance.displayName} session expired.` }
}
throw err
}
},
{
detail: {
tags: ['Submit'],
summary: 'List the accounts you can create repositories in',
description:
'Your GitHub account first, then the organizations it belongs to: the valid `owner` values for scaffolding a request repository. Uses the first linked GitHub identity unless `identityId` is given. Organizations that restrict repository creation are still listed; GitHub refuses the scaffold if you lack the right.',
operationId: 'listRepoOwners',
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
},
query: t.Object({ identityId: t.Optional(t.String({ minLength: 1 })) }),
response: {
200: t.Object({ identityId: t.String(), owners: t.Array(ownerSchema) }),
401: t.Object({ error: t.String() }),
412: t.Object({ error: t.String() }),
},
},
)
125 changes: 125 additions & 0 deletions apps/api/tests/routes/request-repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, spyOn } from 'bun:test'
import { ulid } from 'ulid'
import { clearDb, makeUser, buildApp, userHeaders, type TestUser } from '../helpers'
import { db } from '../../src/db'
import { pluginRequests, pluginRequestClaims } from '../../src/db/schema'

async function seed(user: TestUser) {
const row = {
id: ulid(),
slug: 'trino-presto',
name: 'Trino / Presto',
description: 'Trino driver',
kind: 'driver',
status: 'approved' as const,
requesterId: user.id,
}
await db.insert(pluginRequests).values(row)
return row
}

async function call(user: TestUser, method: string, path: string, body?: unknown) {
const app = await buildApp()
return app.handle(
new Request(`http://localhost${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...userHeaders(user) },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
}

function mockGithub(routes: Record<string, unknown>) {
const spy = spyOn(global, 'fetch').mockImplementation((async (input: string | URL | Request) => {
const url = new URL(String(input))
const data = routes[url.pathname]
return data === undefined
? new Response('{"message":"Not Found"}', { status: 404 })
: new Response(JSON.stringify(data), { status: 200 })
}) as unknown as typeof fetch)
return () => spy.mockRestore()
}

describe('POST /api/requests/:id/repo', () => {
beforeEach(clearDb)

it('requires a claim', async () => {
const user = await makeUser()
const row = await seed(user)
const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, {
repoUrl: 'https://github.com/acme/trino',
})
expect(res.status).toBe(409)
})

it('links a repository the user maintains and records it on the claim', async () => {
const user = await makeUser()
const row = await seed(user)
await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id })
const restore = mockGithub({ '/repos/acme/trino': { permissions: { admin: false, maintain: true } } })
const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, {
repoUrl: 'https://github.com/acme/trino.git',
})
restore()

expect(res.status).toBe(200)
const data = (await res.json()) as { repoUrl: string; fullName: string; claimers: Array<{ repoUrl: string }> }
expect(data.repoUrl).toBe('https://github.com/acme/trino')
expect(data.fullName).toBe('acme/trino')
expect(data.claimers[0].repoUrl).toBe('https://github.com/acme/trino')
const claim = await db.query.pluginRequestClaims.findFirst({ where: { requestId: row.id } })
expect(claim?.repoUrl).toBe('https://github.com/acme/trino')
})

it('refuses a repository without maintain permission', async () => {
const user = await makeUser()
const row = await seed(user)
await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id })
const restore = mockGithub({ '/repos/acme/trino': { permissions: { push: true } } })
const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, {
repoUrl: 'https://github.com/acme/trino',
})
restore()
expect(res.status).toBe(403)
})

it('refuses URLs outside the configured providers', async () => {
const user = await makeUser()
const row = await seed(user)
await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id })
const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, {
repoUrl: 'https://example.com/acme/trino',
})
expect(res.status).toBe(422)
})

it('unlinks with a null repoUrl', async () => {
const user = await makeUser()
const row = await seed(user)
await db
.insert(pluginRequestClaims)
.values({ requestId: row.id, userId: user.id, repoUrl: 'https://github.com/acme/trino' })
const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, { repoUrl: null })
expect(res.status).toBe(200)
const claim = await db.query.pluginRequestClaims.findFirst({ where: { requestId: row.id } })
expect(claim?.repoUrl).toBeNull()
})
})

describe('GET /api/submit/owners', () => {
beforeEach(clearDb)

it('lists the account first, then its organizations', async () => {
const user = await makeUser()
const restore = mockGithub({
'/user': { login: 'builder', avatar_url: 'https://avatars.example/u' },
'/user/orgs': [{ login: 'zeta' }, { login: 'Acme', avatar_url: 'https://avatars.example/a' }],
})
const res = await call(user, 'GET', '/api/submit/owners')
restore()

expect(res.status).toBe(200)
const data = (await res.json()) as { owners: Array<{ login: string; type: string }> }
expect(data.owners.map((o) => `${o.type}:${o.login}`)).toEqual(['user:builder', 'org:Acme', 'org:zeta'])
})
})
2 changes: 1 addition & 1 deletion apps/docs/src/routes/admin/features/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ Plugin requests are reviewed before they become public bounties:

Claimers of a `driver` or `theme` request can create a repository scaffolded by `@tabularis/create-plugin` from `/requests` (`POST /api/requests/:id/scaffold`). GitHub asks for the extra `workflow` scope the first time, because the scaffold includes CI workflows.

Each transition emits a webhook event: `request.created`, `request.approved`, `request.rejected`, `request.claimed`, `request.unclaimed`, `request.scaffolded` and `request.fulfilled`. The **Static site rebuild** preset posts them to a deploy hook, for sites that bake request data in at build time.
Each transition emits a webhook event: `request.created`, `request.approved`, `request.rejected`, `request.claimed`, `request.unclaimed`, `request.scaffolded`, `request.repo_linked` and `request.fulfilled`. The **Static site rebuild** preset posts them to a deploy hook, for sites that bake request data in at build time.

To seed requests from a curated list in the tabularis.dev `pluginBounties.json` format, run `bun run import:bounties [file-or-url]` in `apps/api` (inside the running container: `bun scripts/import-bounties.ts`). It uses the same database as the server — `database.url` from the instance `config.json`, falling back to `DATABASE_URL`. Use `--dry-run` to preview.
9 changes: 8 additions & 1 deletion apps/frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,5 +1217,12 @@
"requests_scaffold_done": "{repo} ist bereit.",
"requests_scaffold_next": "Entwickle das Plugin, veröffentliche ein Release und reiche dann das Repository bei der Registry ein.",
"requests_scaffold_open_repo": "Repository öffnen",
"requests_scaffold_submit": "Bei der Registry einreichen"
"requests_scaffold_submit": "Bei der Registry einreichen",
"requests_scaffold_mode_create": "Neues Repository",
"requests_scaffold_mode_link": "Vorhandenes verknüpfen",
"requests_scaffold_link_repo": "Repository-URL",
"requests_scaffold_link_hint": "Wähle eines deiner Repositories oder füge seine URL ein. Du brauchst Maintain-Rechte, wie beim Einreichen eines Plugins.",
"requests_scaffold_link": "Repository verknüpfen",
"requests_scaffold_linking": "Wird verknüpft…",
"requests_scaffold_linked": "{repo} ist mit deinem Claim verknüpft."
}
Loading
Loading