diff --git a/Dockerfile b/Dockerfile index ab00df95..5749809a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,7 +60,8 @@ RUN printf '%s\n' \ && bun install --production --no-save \ && rm -rf /root/.bun/install/cache \ # Prune type-only and dev-only artifacts not loaded by bun at runtime. - && find node_modules -type f \ + # create-plugin templates are scaffolded into new repos, READMEs included. + && find node_modules -type f -not -path '*/@tabularis/create-plugin/templates/*' \ \( -name '*.cjs.map' -o -name '*.js.map' -o -name '*.mjs.map' \ -o -name '*.d.ts' -o -name '*.d.cts' -o -name '*.d.mts' \ -o -name '*.d.ts.map' -o -name '*.d.cts.map' -o -name '*.d.mts.map' \ diff --git a/apps/api/MCP.md b/apps/api/MCP.md index 407906d1..91c6120f 100644 --- a/apps/api/MCP.md +++ b/apps/api/MCP.md @@ -23,13 +23,13 @@ Effective access is the intersection of granted scopes, the user's current role | `catalog:read` | Plugin search/detail/stats/integrity, docs, pages, kinds, public instance metadata, community requests, manifest validation | | `account:read` | Own profile, linked repositories and transfers | | `plugins:write` | Submit/preview, publish, delete own plugins, transfers, manifest refresh, rehash and yank | -| `requests:write` | Create requests, vote and claim | +| `requests:write` | Create requests, vote, claim and create a scaffolded repository for a claimed request | | `admin::read` | Read an administrative area as a current admin | | `admin::write` | Change an administrative area as a current admin | Admin areas: `plugins`, `users`, `manifest`, `docs`, `pages`, `settings`, `provider-instances`, `features`, `kinds`, `branding`, `home-copy`, `i18n`, `instance`, `infra`, `audit`, `diagnostics`, `requests`. Unknown scopes are rejected. A non-admin cannot grant admin scopes. Role demotion removes access on the next request even if the token has not expired. -Examples: a catalog assistant requests `catalog:read`; a publishing assistant requests `catalog:read account:read plugins:write`; a moderation assistant requests `catalog:read admin:plugins:read admin:plugins:write`. The default when scope is omitted is only `catalog:read`. +Examples: a catalog assistant requests `catalog:read`; a publishing assistant requests `catalog:read account:read plugins:write`; a moderation assistant requests `catalog:read admin:plugins:read admin:plugins:write`. A request-review assistant requests `catalog:read admin:requests:write` to list pending requests and approve, reject or edit their bounty details. The default when scope is omitted is only `catalog:read`. Existing `tbm_` admin API tokens now enforce these exact area scopes too. Legacy `scopes: null` retains full-admin access; unknown legacy scope strings grant no matching action. Scoped credentials cannot create tokens or alter recovery authentication. Browser session JWTs and publisher/admin API tokens are not accepted as MCP access tokens. diff --git a/apps/api/package.json b/apps/api/package.json index 2cfbc17c..357a4ab2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,7 +26,8 @@ "generate": "bunx drizzle-kit generate", "generate:pg": "bunx drizzle-kit generate --config=drizzle.pg.config.ts", "generate:mysql": "bunx drizzle-kit generate --config=drizzle.mysql.config.ts", - "export:manifest-schema": "bun scripts/export-manifest-schema.ts" + "export:manifest-schema": "bun scripts/export-manifest-schema.ts", + "import:bounties": "bun scripts/import-bounties.ts" }, "devDependencies": { "@tabularium/tsconfig": "workspace:*", @@ -44,6 +45,7 @@ "@elysiajs/static": "^1.4.10", "@modelcontextprotocol/sdk": "1.32.1", "@sinclair/typebox": "^0.34.49", + "@tabularis/create-plugin": "0.4.1", "@tabularium/manifest": "workspace:*", "arctic": "^3.7.0", "croner": "^10.0.1", diff --git a/apps/api/scripts/import-bounties.ts b/apps/api/scripts/import-bounties.ts new file mode 100644 index 00000000..3a8b4a2b --- /dev/null +++ b/apps/api/scripts/import-bounties.ts @@ -0,0 +1,149 @@ +/** + * One-off import of a hand-curated bounty board (the tabularis.dev + * `pluginBounties.json` format) into plugin requests. + * + * bun scripts/import-bounties.ts [file-or-url] [--map bounty=plugin ...] [--kind driver] [--dry-run] + * + * Runs against DATABASE_URL like the seed scripts. Each bounty becomes a + * request keyed by its `id` (the slug): active ones `approved` with their + * board metadata, shipped ones `fulfilled` and linked to the registry plugin + * with the same id (or the one given with --map). Existing slugs are updated + * in place, so the script can be re-run. Requests are filed by the first admin. + */ +import { parseArgs } from 'node:util' +import { readFile } from 'node:fs/promises' +import { ulid } from 'ulid' +import { eq } from 'drizzle-orm' +import { connectDB, db } from '../src/db' +import { pluginRequests } from '../src/db/schema' +import type { Bounty } from '../src/lib/request-bounty' +import { parseBounty } from '../src/lib/request-bounty' + +const DEFAULT_SOURCE = 'https://raw.githubusercontent.com/TabularisDB/website/main/src/data/pluginBounties.json' +// Board entries whose plugin ships under a different id. +const DEFAULT_ALIASES: Record = { 'sql-server': 'sqlserver', scylladb: 'cassandra' } + +type SourceBounty = { + id: string + name: string + target?: string + status: string + tagline?: string + description: string + stage?: string + nextStep?: string + difficulty?: Bounty['difficulty'] + accent?: string + radar?: Bounty['radar'] + tags?: string[] + capabilities?: Bounty['capabilities'] + signal?: string + sponsorUrl?: string +} + +const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), + allowPositionals: true, + options: { + map: { type: 'string', multiple: true }, + kind: { type: 'string', default: 'driver' }, + 'dry-run': { type: 'boolean', default: false }, + }, +}) + +const source = positionals[0] ?? DEFAULT_SOURCE +const aliases = { ...DEFAULT_ALIASES } +for (const pair of values.map ?? []) { + const [from, to] = pair.split('=') + if (from && to) aliases[from] = to +} +const dryRun = values['dry-run'] + +const raw = /^https?:\/\//.test(source) ? await (await fetch(source)).text() : await readFile(source, 'utf8') +const bounties = (JSON.parse(raw) as { bounties: SourceBounty[] }).bounties + +await connectDB(process.env.DATABASE_URL ?? './data/registry.db') +const admin = await db.query.users.findFirst({ where: { role: 'admin' }, columns: { id: true } }) +if (!admin) { + console.error('No admin user to file the requests under — sign in once first.') + process.exit(1) +} + +function boardStatus(status: string): Bounty['boardStatus'] { + if (status === 'most-wanted' || status === 'scoped' || status === 'coming-soon') return status + // "claimed" referred to people outside the registry; claims are real users now. + if (status === 'claimed') return 'scoped' + return 'open' +} + +function bountyOf(b: SourceBounty): Bounty { + const bounty: Bounty = { + boardStatus: boardStatus(b.status), + tagline: b.tagline, + stage: b.stage, + nextStep: b.nextStep, + difficulty: b.difficulty, + accent: b.accent, + radar: b.radar, + tags: b.tags, + capabilities: b.capabilities, + signal: b.signal, + sponsorUrl: b.sponsorUrl, + } + for (const key of Object.keys(bounty) as (keyof Bounty)[]) if (bounty[key] === undefined) delete bounty[key] + return bounty +} + +const now = Date.now() +const report = { created: 0, updated: 0, fulfilled: 0, skipped: [] as string[] } + +for (const b of bounties) { + const bounty = bountyOf(b) + const stored = JSON.stringify(bounty) + if (!parseBounty(stored)) { + report.skipped.push(`${b.id} (bounty metadata does not match the schema)`) + continue + } + + let status: 'approved' | 'fulfilled' = 'approved' + let fulfilledPluginId: string | null = null + if (b.status === 'shipped') { + status = 'fulfilled' + const pluginId = aliases[b.id] ?? b.id + const plugin = await db.query.plugins.findFirst({ where: { id: pluginId }, columns: { id: true } }) + // Built into the app (no plugin), still shown as shipped. + fulfilledPluginId = plugin?.id ?? null + if (!plugin) console.warn(`! ${b.id}: shipped without a registry plugin '${pluginId}', imported unlinked`) + } + + const fields = { + name: b.name.slice(0, 120), + description: b.description.slice(0, 2000), + kind: values.kind ?? null, + status, + bounty: stored, + reviewedBy: admin.id, + reviewedAt: now, + fulfilledPluginId, + fulfilledAt: status === 'fulfilled' ? now : null, + updatedAt: now, + } + if (status === 'fulfilled') report.fulfilled++ + + const existing = await db.query.pluginRequests.findFirst({ where: { slug: b.id }, columns: { id: true } }) + console.log(`${existing ? '~' : '+'} ${b.id} → ${status}${fulfilledPluginId ? ` (${fulfilledPluginId})` : ''}`) + if (dryRun) continue + if (existing) { + await db.update(pluginRequests).set(fields).where(eq(pluginRequests.id, existing.id)) + report.updated++ + } else { + await db.insert(pluginRequests).values({ id: ulid(), slug: b.id, requesterId: admin.id, ...fields }) + report.created++ + } +} + +console.log( + `\n${dryRun ? '[dry run] ' : ''}${report.created} created, ${report.updated} updated, ${report.fulfilled} shipped.`, +) +for (const s of report.skipped) console.warn(`skipped ${s}`) +process.exit(0) diff --git a/apps/api/src/db/migrations.mysql/20261013120000_request_bounty/migration.sql b/apps/api/src/db/migrations.mysql/20261013120000_request_bounty/migration.sql new file mode 100644 index 00000000..92b72976 --- /dev/null +++ b/apps/api/src/db/migrations.mysql/20261013120000_request_bounty/migration.sql @@ -0,0 +1,24 @@ +-- Bounty lifecycle for community requests. See sqlite migration for rationale. +ALTER TABLE `plugin_requests` ADD `status` varchar(16) DEFAULT 'pending' NOT NULL; +--> statement-breakpoint +UPDATE `plugin_requests` SET `status` = 'approved'; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `rejection_reason` varchar(500); +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `reviewed_by` varchar(64); +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `reviewed_at` bigint; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `fulfilled_plugin_id` varchar(80); +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `fulfilled_at` bigint; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `bounty` text; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `updated_at` bigint; +--> statement-breakpoint +ALTER TABLE `plugin_request_claims` ADD `repo_url` varchar(500); +--> statement-breakpoint +ALTER TABLE `plugin_request_claims` ADD `scaffolded_at` bigint; +--> statement-breakpoint +CREATE INDEX `plugin_requests_status_idx` ON `plugin_requests` (`status`); diff --git a/apps/api/src/db/migrations.pg/20261013120000_request_bounty/migration.sql b/apps/api/src/db/migrations.pg/20261013120000_request_bounty/migration.sql new file mode 100644 index 00000000..375bd3d1 --- /dev/null +++ b/apps/api/src/db/migrations.pg/20261013120000_request_bounty/migration.sql @@ -0,0 +1,24 @@ +-- Bounty lifecycle for community requests. See sqlite migration for rationale. +ALTER TABLE "plugin_requests" ADD COLUMN "status" text DEFAULT 'pending' NOT NULL; +--> statement-breakpoint +UPDATE "plugin_requests" SET "status" = 'approved'; +--> statement-breakpoint +ALTER TABLE "plugin_requests" ADD COLUMN "rejection_reason" text; +--> statement-breakpoint +ALTER TABLE "plugin_requests" ADD COLUMN "reviewed_by" text; +--> statement-breakpoint +ALTER TABLE "plugin_requests" ADD COLUMN "reviewed_at" bigint; +--> statement-breakpoint +ALTER TABLE "plugin_requests" ADD COLUMN "fulfilled_plugin_id" text; +--> statement-breakpoint +ALTER TABLE "plugin_requests" ADD COLUMN "fulfilled_at" bigint; +--> statement-breakpoint +ALTER TABLE "plugin_requests" ADD COLUMN "bounty" text; +--> statement-breakpoint +ALTER TABLE "plugin_requests" ADD COLUMN "updated_at" bigint; +--> statement-breakpoint +ALTER TABLE "plugin_request_claims" ADD COLUMN "repo_url" text; +--> statement-breakpoint +ALTER TABLE "plugin_request_claims" ADD COLUMN "scaffolded_at" bigint; +--> statement-breakpoint +CREATE INDEX "plugin_requests_status_idx" ON "plugin_requests" ("status"); diff --git a/apps/api/src/db/migrations/20261013120000_request_bounty/migration.sql b/apps/api/src/db/migrations/20261013120000_request_bounty/migration.sql new file mode 100644 index 00000000..457ecf5b --- /dev/null +++ b/apps/api/src/db/migrations/20261013120000_request_bounty/migration.sql @@ -0,0 +1,30 @@ +-- Bounty lifecycle for community requests. +-- `status`: new requests wait in `pending` until an admin reviews them; +-- `approved` ones are public bounties, `fulfilled` ones are linked to the +-- plugin that shipped them. Requests created before review existed were +-- already public, so they start out approved. +ALTER TABLE `plugin_requests` ADD `status` text DEFAULT 'pending' NOT NULL; +--> statement-breakpoint +UPDATE `plugin_requests` SET `status` = 'approved'; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `rejection_reason` text; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `reviewed_by` text; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `reviewed_at` integer; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `fulfilled_plugin_id` text; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `fulfilled_at` integer; +--> statement-breakpoint +-- Bounty board metadata as JSON (tagline, scope, difficulty, board status…). +ALTER TABLE `plugin_requests` ADD `bounty` text; +--> statement-breakpoint +ALTER TABLE `plugin_requests` ADD `updated_at` integer; +--> statement-breakpoint +-- Repository scaffolded for a claim by POST /api/requests/:id/scaffold. +ALTER TABLE `plugin_request_claims` ADD `repo_url` text; +--> statement-breakpoint +ALTER TABLE `plugin_request_claims` ADD `scaffolded_at` integer; +--> statement-breakpoint +CREATE INDEX `plugin_requests_status_idx` ON `plugin_requests` (`status`); diff --git a/apps/api/src/db/schema.mysql.ts b/apps/api/src/db/schema.mysql.ts index 6e827fca..9d1cdee2 100644 --- a/apps/api/src/db/schema.mysql.ts +++ b/apps/api/src/db/schema.mysql.ts @@ -161,18 +161,34 @@ export const releaseAssets = mysqlTable( }), ) -export const pluginRequests = mysqlTable('plugin_requests', { - id: id('id').primaryKey(), - slug: varchar('slug', { length: 80 }).notNull().unique(), - name: varchar('name', { length: 120 }).notNull(), - description: varchar('description', { length: 500 }).notNull(), - kind: varchar('kind', { length: 40 }), - requesterId: id('requester_id') - .notNull() - .references(() => users.id), - upvotes: int('upvotes').notNull().default(0), - createdAt: ts('created_at').notNull().$defaultFn(now), -}) +export const pluginRequests = mysqlTable( + 'plugin_requests', + { + id: id('id').primaryKey(), + slug: varchar('slug', { length: 80 }).notNull().unique(), + name: varchar('name', { length: 120 }).notNull(), + description: varchar('description', { length: 500 }).notNull(), + kind: varchar('kind', { length: 40 }), + requesterId: id('requester_id') + .notNull() + .references(() => users.id), + upvotes: int('upvotes').notNull().default(0), + status: varchar('status', { length: 16, enum: ['pending', 'approved', 'rejected', 'fulfilled'] }) + .notNull() + .default('pending'), + rejectionReason: varchar('rejection_reason', { length: 500 }), + reviewedBy: id('reviewed_by'), + reviewedAt: ts('reviewed_at'), + fulfilledPluginId: varchar('fulfilled_plugin_id', { length: 80 }), + fulfilledAt: ts('fulfilled_at'), + bounty: text('bounty'), + createdAt: ts('created_at').notNull().$defaultFn(now), + updatedAt: ts('updated_at'), + }, + (t) => ({ + byStatus: index('plugin_requests_status_idx').on(t.status), + }), +) export const pluginRequestVotes = mysqlTable( 'plugin_request_votes', @@ -198,6 +214,8 @@ export const pluginRequestClaims = mysqlTable( userId: id('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), + repoUrl: varchar('repo_url', { length: 500 }), + scaffoldedAt: ts('scaffolded_at'), createdAt: ts('created_at').notNull().$defaultFn(now), }, (t) => ({ diff --git a/apps/api/src/db/schema.pg.ts b/apps/api/src/db/schema.pg.ts index 8d5c38c2..eaa99ede 100644 --- a/apps/api/src/db/schema.pg.ts +++ b/apps/api/src/db/schema.pg.ts @@ -161,18 +161,34 @@ export const releaseAssets = pgTable( }), ) -export const pluginRequests = pgTable('plugin_requests', { - id: text('id').primaryKey(), - slug: text('slug').notNull().unique(), - name: text('name').notNull(), - description: text('description').notNull(), - kind: text('kind'), - requesterId: text('requester_id') - .notNull() - .references(() => users.id), - upvotes: integer('upvotes').notNull().default(0), - createdAt: ts('created_at').notNull().$defaultFn(now), -}) +export const pluginRequests = pgTable( + 'plugin_requests', + { + id: text('id').primaryKey(), + slug: text('slug').notNull().unique(), + name: text('name').notNull(), + description: text('description').notNull(), + kind: text('kind'), + requesterId: text('requester_id') + .notNull() + .references(() => users.id), + upvotes: integer('upvotes').notNull().default(0), + status: text('status', { enum: ['pending', 'approved', 'rejected', 'fulfilled'] }) + .notNull() + .default('pending'), + rejectionReason: text('rejection_reason'), + reviewedBy: text('reviewed_by'), + reviewedAt: ts('reviewed_at'), + fulfilledPluginId: text('fulfilled_plugin_id'), + fulfilledAt: ts('fulfilled_at'), + bounty: text('bounty'), + createdAt: ts('created_at').notNull().$defaultFn(now), + updatedAt: ts('updated_at'), + }, + (t) => ({ + byStatus: index('plugin_requests_status_idx').on(t.status), + }), +) export const pluginRequestVotes = pgTable( 'plugin_request_votes', @@ -198,6 +214,8 @@ export const pluginRequestClaims = pgTable( userId: text('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), + repoUrl: text('repo_url'), + scaffoldedAt: ts('scaffolded_at'), createdAt: ts('created_at').notNull().$defaultFn(now), }, (t) => ({ diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 5a3b6e17..607ef277 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -170,19 +170,39 @@ export const releaseAssets = sqliteTable( }), ) -export const pluginRequests = sqliteTable('plugin_requests', { - id: text('id').primaryKey(), - slug: text('slug').notNull().unique(), - name: text('name').notNull(), - description: text('description').notNull(), - // Optional plugin kind (admin-defined key, see lib/kinds). NULL = unspecified. - kind: text('kind'), - requesterId: text('requester_id') - .notNull() - .references(() => users.id), - upvotes: integer('upvotes').notNull().default(0), - createdAt: integer('created_at').notNull().$defaultFn(now), -}) +export const pluginRequests = sqliteTable( + 'plugin_requests', + { + id: text('id').primaryKey(), + slug: text('slug').notNull().unique(), + name: text('name').notNull(), + description: text('description').notNull(), + // Optional plugin kind (admin-defined key, see lib/kinds). NULL = unspecified. + kind: text('kind'), + requesterId: text('requester_id') + .notNull() + .references(() => users.id), + upvotes: integer('upvotes').notNull().default(0), + // Review lifecycle: pending (hidden until an admin reviews it) → approved + // (public bounty) or rejected; approved → fulfilled once a plugin with the + // same slug is approved in the registry. + status: text('status', { enum: ['pending', 'approved', 'rejected', 'fulfilled'] }) + .notNull() + .default('pending'), + rejectionReason: text('rejection_reason'), + reviewedBy: text('reviewed_by'), + reviewedAt: integer('reviewed_at'), + fulfilledPluginId: text('fulfilled_plugin_id'), + fulfilledAt: integer('fulfilled_at'), + // Bounty board metadata (JSON, see lib/request-bounty). NULL until an admin fills it in. + bounty: text('bounty'), + createdAt: integer('created_at').notNull().$defaultFn(now), + updatedAt: integer('updated_at'), + }, + (t) => ({ + byStatus: index('plugin_requests_status_idx').on(t.status), + }), +) export const pluginRequestVotes = sqliteTable( 'plugin_request_votes', @@ -208,6 +228,9 @@ export const pluginRequestClaims = sqliteTable( userId: text('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), + // Repository created for this claim by POST /api/requests/:id/scaffold. + repoUrl: text('repo_url'), + scaffoldedAt: integer('scaffolded_at'), createdAt: integer('created_at').notNull().$defaultFn(now), }, (t) => ({ diff --git a/apps/api/src/lib/claims.ts b/apps/api/src/lib/claims.ts index 86f44825..7b48a96c 100644 --- a/apps/api/src/lib/claims.ts +++ b/apps/api/src/lib/claims.ts @@ -1,4 +1,4 @@ -import { and, eq, count } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { db, getDialect } from '$db' import { pluginRequestClaims } from '$db/schema' @@ -22,14 +22,6 @@ export async function unclaimRequest(requestId: string, userId: string): Promise .where(and(eq(pluginRequestClaims.requestId, requestId), eq(pluginRequestClaims.userId, userId))) } -export async function claimCount(requestId: string): Promise { - const rows = await db - .select({ n: count() }) - .from(pluginRequestClaims) - .where(eq(pluginRequestClaims.requestId, requestId)) - return rows[0]?.n ?? 0 -} - export async function hasClaimedRequest(requestId: string, userId: string): Promise { const rows = await db .select({ requestId: pluginRequestClaims.requestId }) diff --git a/apps/api/src/lib/manifest-apply.ts b/apps/api/src/lib/manifest-apply.ts index f8315b74..9ae485d9 100644 --- a/apps/api/src/lib/manifest-apply.ts +++ b/apps/api/src/lib/manifest-apply.ts @@ -62,6 +62,7 @@ export type PluginManifestUpdate = { } import { jsonArrayOrNull as jsonArray } from './util' +import { fulfillRequestForPlugin } from './request-fulfillment' /** * Convert a parsed manifest into a column patch for the `plugins` row. @@ -125,4 +126,7 @@ export async function applyManifestToPlugin(slug: string, patch: PluginManifestU // plugins were stuck on a repo-derived label that the manifest never // matched), so it's gone. await db.update(plugins).set(patch).where(eq(plugins.id, slug)) + // The first ingested manifest is what puts a plugin in the catalog, so it + // may be the moment a matching request ships. + if (patch.manifestVersion) await fulfillRequestForPlugin(slug) } diff --git a/apps/api/src/lib/mcp/catalog.ts b/apps/api/src/lib/mcp/catalog.ts index 6d6a8c91..b9674e68 100644 --- a/apps/api/src/lib/mcp/catalog.ts +++ b/apps/api/src/lib/mcp/catalog.ts @@ -10,6 +10,7 @@ const groups: Record = { 'getPluginDownloadStats', 'getReleaseIntegrity', 'listRequests', + 'getRequest', 'listKinds', 'listPages', 'getPage', @@ -37,7 +38,7 @@ const groups: Record = { 'authorRehashRelease', 'authorYankRelease', ], - 'requests:write': ['createRequest', 'toggleRequestClaim', 'toggleUpvote'], + 'requests:write': ['createRequest', 'toggleRequestClaim', 'toggleUpvote', 'scaffoldRequest'], } // Explicit allowlist: new REST endpoints are never exposed automatically. const adminOperations = new Set([ @@ -53,6 +54,7 @@ const adminOperations = new Set([ 'adminGetHomeCopy', 'adminUpdateHomeCopy', 'adminDeleteRequest', + 'adminUpdateRequest', 'listUsers', 'getUser', 'patchUser', diff --git a/apps/api/src/lib/optional-viewer.ts b/apps/api/src/lib/optional-viewer.ts new file mode 100644 index 00000000..7764e473 --- /dev/null +++ b/apps/api/src/lib/optional-viewer.ts @@ -0,0 +1,23 @@ +import { verifySessionToken, delegatedAccess } from '$lib/access' + +/** + * Viewer for public endpoints that personalise their response (e.g. + * `claimedByMe`) without requiring auth: delegated access first, then a + * Bearer token, then the session cookie. Invalid credentials mean anonymous. + */ +export async function resolveOptionalViewer( + request: Request, + headers: Record, + cookie: Record, +): Promise<{ sub: string } | null> { + const delegated = delegatedAccess(request) + if (delegated) return { sub: delegated.user.sub } + const authHeader = headers.authorization + const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined + const raw = cookie.auth?.value + const cookieToken = typeof raw === 'string' ? raw : undefined + const token = bearerToken ?? cookieToken + if (!token) return null + const payload = await verifySessionToken(token) + return payload ? { sub: payload.sub } : null +} diff --git a/apps/api/src/lib/request-bounty.ts b/apps/api/src/lib/request-bounty.ts new file mode 100644 index 00000000..d9167c67 --- /dev/null +++ b/apps/api/src/lib/request-bounty.ts @@ -0,0 +1,66 @@ +import { t, type Static } from 'elysia' +import { Value } from '@sinclair/typebox/value' + +export const REQUEST_STATUSES = ['pending', 'approved', 'rejected', 'fulfilled'] as const +export type RequestStatus = (typeof REQUEST_STATUSES)[number] + +// Statuses anyone can list. `pending` and `rejected` are only visible to +// admins and to the user who filed the request. +export const PUBLIC_REQUEST_STATUSES: RequestStatus[] = ['approved', 'fulfilled'] + +export function isRequestStatus(v: string): v is RequestStatus { + return (REQUEST_STATUSES as readonly string[]).includes(v) +} + +// Label an admin picks for an approved request that nobody is working on yet. +// "In progress" (claims > 0) and "shipped" (fulfilled) are derived, never set. +export const BOARD_STATUSES = ['open', 'most-wanted', 'scoped', 'coming-soon'] as const + +export const bountySchema = t.Object( + { + boardStatus: t.Optional( + t.Union([t.Literal('open'), t.Literal('most-wanted'), t.Literal('scoped'), t.Literal('coming-soon')], { + description: 'Board label while nobody has claimed the request. Default `open`.', + }), + ), + tagline: t.Optional(t.String({ maxLength: 200 })), + stage: t.Optional(t.String({ maxLength: 120 })), + nextStep: t.Optional(t.String({ maxLength: 1000 })), + difficulty: t.Optional(t.Union([t.Literal('Low'), t.Literal('Medium'), t.Literal('High'), t.Literal('Extreme')])), + accent: t.Optional(t.String({ pattern: '^#[0-9a-fA-F]{6}$', description: 'Hex color, e.g. `#ff5a3d`.' })), + radar: t.Optional( + t.Object({ + x: t.Number({ minimum: 0, maximum: 100 }), + y: t.Number({ minimum: 0, maximum: 100 }), + scale: t.Number({ minimum: 0.1, maximum: 3 }), + }), + ), + tags: t.Optional(t.Array(t.String({ minLength: 1, maxLength: 40 }), { maxItems: 12 })), + capabilities: t.Optional( + t.Array( + t.Object({ + label: t.String({ minLength: 1, maxLength: 60 }), + state: t.Union([t.Literal('ready'), t.Literal('needed'), t.Literal('stretch')]), + }), + { maxItems: 24 }, + ), + ), + signal: t.Optional(t.String({ maxLength: 500 })), + sponsorUrl: t.Optional(t.String({ maxLength: 500 })), + }, + { additionalProperties: false, description: 'Bounty board metadata shown on the request.' }, +) + +export type Bounty = Static + +// Stored as JSON text. Anything unreadable or no longer matching the schema +// is treated as "no metadata" rather than breaking the listing. +export function parseBounty(raw: string | null | undefined): Bounty | null { + if (!raw) return null + try { + const value = JSON.parse(raw) as unknown + return Value.Check(bountySchema, value) ? value : null + } catch { + return null + } +} diff --git a/apps/api/src/lib/request-fulfillment.ts b/apps/api/src/lib/request-fulfillment.ts new file mode 100644 index 00000000..6781e854 --- /dev/null +++ b/apps/api/src/lib/request-fulfillment.ts @@ -0,0 +1,39 @@ +import { and, eq, inArray } from 'drizzle-orm' +import { db } from '$db' +import { pluginRequests } from '$db/schema' +import { logger } from '$lib/logger' +import { emitEvent, type EventActor } from '$lib/webhooks/events' + +const log = logger.child({ module: 'request-fulfillment' }) + +/** + * Close the request whose slug matches a plugin once that plugin is in the + * catalog: approved, with a manifest ingested from a release. Called whenever + * either condition may have just become true (approval, manifest applied). + * The request keeps its votes and claims for history and moves to + * `fulfilled`, linked to the plugin. Rejected and already fulfilled requests + * are left alone. Never throws: fulfillment must not break the caller. + */ +export async function fulfillRequestForPlugin(pluginId: string, actor: EventActor = null): Promise { + try { + const plugin = await db.query.plugins.findFirst({ + where: { id: pluginId }, + columns: { status: true, manifestVersion: true }, + }) + if (!plugin || plugin.status !== 'approved' || !plugin.manifestVersion) return + const request = await db.query.pluginRequests.findFirst({ where: { slug: pluginId } }) + if (!request || (request.status !== 'approved' && request.status !== 'pending')) return + const now = Date.now() + await db + .update(pluginRequests) + .set({ status: 'fulfilled', fulfilledPluginId: pluginId, fulfilledAt: now, updatedAt: now }) + .where(and(eq(pluginRequests.id, request.id), inArray(pluginRequests.status, ['approved', 'pending']))) + await emitEvent('request.fulfilled', { + pluginId, + actor, + request: { ...request, status: 'fulfilled' }, + }) + } catch (err) { + log.warn({ err, pluginId }, 'failed to fulfill matching plugin request') + } +} diff --git a/apps/api/src/lib/request-view.ts b/apps/api/src/lib/request-view.ts new file mode 100644 index 00000000..04cceddc --- /dev/null +++ b/apps/api/src/lib/request-view.ts @@ -0,0 +1,153 @@ +import { t } from 'elysia' +import { and, eq, inArray } from 'drizzle-orm' +import { db } from '$db' +import { identities, plugins, pluginRequestClaims, pluginRequestVotes, providerInstances, users } from '$db/schema' +import { bountySchema, parseBounty } from '$lib/request-bounty' + +type RequestRow = typeof import('$db/schema').pluginRequests.$inferSelect + +export const claimerSchema = t.Object({ + userId: t.String(), + username: t.Nullable(t.String()), + displayName: t.String(), + avatarUrl: t.Nullable(t.String()), + repoUrl: t.Nullable(t.String()), + claimedAt: t.Number(), +}) + +export const requestViewSchema = t.Object({ + id: t.String(), + slug: t.String(), + name: t.String(), + description: t.String(), + kind: t.Nullable(t.String()), + requesterId: t.String(), + status: t.Union([t.Literal('pending'), t.Literal('approved'), t.Literal('rejected'), t.Literal('fulfilled')]), + rejectionReason: t.Nullable(t.String()), + bounty: t.Nullable(bountySchema), + upvotes: t.Number(), + createdAt: t.Number(), + updatedAt: t.Nullable(t.Number()), + fulfilledAt: t.Nullable(t.Number()), + fulfilledPlugin: t.Nullable(t.Object({ id: t.String(), name: t.String() })), + claims: t.Number(), + claimers: t.Array(claimerSchema), + claimedByMe: t.Boolean(), + votedByMe: t.Boolean(), +}) + +export type Claimer = typeof claimerSchema.static +export type RequestView = typeof requestViewSchema.static + +// GitHub avatars are addressable by the numeric account id, which survives +// renames. Other providers have no stable unauthenticated avatar URL. +function avatarUrl(kind: string, externalId: string): string | null { + return kind === 'github' ? `https://avatars.githubusercontent.com/u/${encodeURIComponent(externalId)}?v=4` : null +} + +/** Claimers per request, oldest claim first, with their repo when scaffolded. */ +export async function loadClaimers(requestIds: string[]): Promise> { + const out = new Map() + if (requestIds.length === 0) return out + const rows = await db + .select({ + requestId: pluginRequestClaims.requestId, + userId: pluginRequestClaims.userId, + repoUrl: pluginRequestClaims.repoUrl, + claimedAt: pluginRequestClaims.createdAt, + displayName: users.displayName, + }) + .from(pluginRequestClaims) + .innerJoin(users, eq(users.id, pluginRequestClaims.userId)) + .where(inArray(pluginRequestClaims.requestId, requestIds)) + .orderBy(pluginRequestClaims.createdAt) + + const userIds = [...new Set(rows.map((r) => r.userId))] + const idRows = + userIds.length === 0 + ? [] + : await db + .select({ + userId: identities.userId, + username: identities.username, + externalId: identities.externalId, + kind: providerInstances.kind, + createdAt: identities.createdAt, + }) + .from(identities) + .innerJoin(providerInstances, eq(providerInstances.id, identities.providerInstanceId)) + .where(inArray(identities.userId, userIds)) + .orderBy(identities.createdAt) + // First linked identity wins, preferring GitHub for the avatar. + const identityByUser = new Map() + for (const row of idRows) { + const current = identityByUser.get(row.userId) + if (!current || (current.kind !== 'github' && row.kind === 'github')) identityByUser.set(row.userId, row) + } + + for (const row of rows) { + const identity = identityByUser.get(row.userId) + const list = out.get(row.requestId) ?? [] + list.push({ + userId: row.userId, + username: identity?.username ?? null, + displayName: row.displayName, + avatarUrl: identity ? avatarUrl(identity.kind, identity.externalId) : null, + repoUrl: row.repoUrl ?? null, + claimedAt: row.claimedAt, + }) + out.set(row.requestId, list) + } + return out +} + +/** Public shape of requests, with per-viewer flags when a viewer is known. */ +export async function toRequestViews(rows: RequestRow[], viewerId: string | null): Promise { + const ids = rows.map((r) => r.id) + const claimers = await loadClaimers(ids) + + let voted = new Set() + if (viewerId && ids.length > 0) { + const mine = await db + .select({ requestId: pluginRequestVotes.requestId }) + .from(pluginRequestVotes) + .where(and(eq(pluginRequestVotes.userId, viewerId), inArray(pluginRequestVotes.requestId, ids))) + voted = new Set(mine.map((r) => r.requestId)) + } + + const pluginIds = [...new Set(rows.map((r) => r.fulfilledPluginId).filter((v): v is string => !!v))] + const pluginRows = + pluginIds.length === 0 + ? [] + : await db.select({ id: plugins.id, name: plugins.name }).from(plugins).where(inArray(plugins.id, pluginIds)) + const pluginById = new Map(pluginRows.map((p) => [p.id, p])) + + return rows.map((r) => { + const list = claimers.get(r.id) ?? [] + return { + id: r.id, + slug: r.slug, + name: r.name, + description: r.description, + kind: r.kind ?? null, + requesterId: r.requesterId, + status: r.status, + rejectionReason: r.status === 'rejected' ? (r.rejectionReason ?? null) : null, + bounty: parseBounty(r.bounty), + upvotes: r.upvotes, + createdAt: r.createdAt, + updatedAt: r.updatedAt ?? null, + fulfilledAt: r.fulfilledAt ?? null, + fulfilledPlugin: r.fulfilledPluginId ? (pluginById.get(r.fulfilledPluginId) ?? null) : null, + claims: list.length, + claimers: list, + claimedByMe: viewerId ? list.some((c) => c.userId === viewerId) : false, + votedByMe: voted.has(r.id), + } + }) +} + +export async function toRequestView(row: RequestRow, viewerId: string | null): Promise { + const [view] = await toRequestViews([row], viewerId) + return view +} diff --git a/apps/api/src/lib/return-to.ts b/apps/api/src/lib/return-to.ts new file mode 100644 index 00000000..418c434c --- /dev/null +++ b/apps/api/src/lib/return-to.ts @@ -0,0 +1,23 @@ +import { env, allowedOrigins } from '$lib/env' + +/** + * Validate a post-sign-in destination: a path on the web app, or a full URL on + * a site trusted for CORS (e.g. a project website hosting the request form). + * Paths come back relative, trusted URLs absolute; anything else is null. + */ +export function safeReturnTo(raw: string | undefined): string | null { + if (!raw || typeof raw !== 'string' || raw.length > 512) return null + try { + const base = env.WEB_BASE_URL ?? env.BASE_URL + const baseOrigin = new URL(base).origin + const target = new URL(raw, base) + if (target.origin === baseOrigin) return target.pathname + target.search + target.hash + // Sites trusted for CORS (e.g. a project website hosting the request form) + // may also get the user back after sign-in; they keep their full URL. + if ((target.protocol === 'https:' || target.protocol === 'http:') && allowedOrigins().includes(target.origin)) + return target.href + return null + } catch { + return null + } +} diff --git a/apps/api/src/lib/scaffold/generate.ts b/apps/api/src/lib/scaffold/generate.ts new file mode 100644 index 00000000..ed6cca4a --- /dev/null +++ b/apps/api/src/lib/scaffold/generate.ts @@ -0,0 +1,121 @@ +import { mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative } from 'node:path' + +// Plugin kinds @tabularis/create-plugin can scaffold. +export const SCAFFOLD_KINDS = ['driver', 'theme'] as const +export type ScaffoldKind = (typeof SCAFFOLD_KINDS)[number] + +export function isScaffoldKind(v: string | null | undefined): v is ScaffoldKind { + return !!v && (SCAFFOLD_KINDS as readonly string[]).includes(v) +} + +export type DriverOptions = { + dbType: 'network' | 'file' | 'folder' | 'api' + quote: '"' | '`' + withUi: boolean +} + +export type ScaffoldInput = + | { kind: 'driver'; slug: string; driver: DriverOptions } + | { kind: 'theme'; slug: string; minRuntimeVersion: string } + +export type ScaffoldFile = { path: string; content: Buffer; executable: boolean } + +export class ScaffoldError extends Error {} + +// Same rule the scaffolder enforces on plugin names. +export const SCAFFOLD_SLUG_RE = /^[a-z][a-z0-9-]*[a-z0-9]$/ + +function packageDir(): string { + const require = createRequire(import.meta.url) + return dirname(require.resolve('@tabularis/create-plugin/package.json')) +} + +async function walk(root: string, dir = root): Promise { + const out: string[] = [] + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) out.push(...(await walk(root, full))) + else if (entry.isFile()) out.push(relative(root, full)) + } + return out +} + +function cliArgs(input: ScaffoldInput, target: string): string[] { + const dist = join(packageDir(), 'dist') + if (input.kind === 'theme') { + return [ + join(dist, 'theme.js'), + 'scaffold', + input.slug, + '--dir', + target, + '--min-runtime-version', + input.minRuntimeVersion, + ] + } + const args = [ + join(dist, 'cli.js'), + input.slug, + '--no-git', + '--dir', + target, + '--db-type', + input.driver.dbType, + '--quote', + input.driver.quote, + ] + if (input.driver.withUi) args.push('--with-ui') + return args +} + +/** + * Generate a plugin project with the published @tabularis/create-plugin CLI + * (the same templates `npm create @tabularis/plugin` uses) and return its + * files. The CLI writes to disk, so it runs in a throwaway directory. + */ +export async function generatePluginFiles(input: ScaffoldInput): Promise { + if (!SCAFFOLD_SLUG_RE.test(input.slug)) { + throw new ScaffoldError(`'${input.slug}' is not a valid plugin name for the scaffolder.`) + } + const work = await mkdtemp(join(tmpdir(), 'tabularium-scaffold-')) + const target = join(work, 'plugin') + try { + const proc = Bun.spawn([process.execPath, ...cliArgs(input, target)], { + cwd: work, + stdout: 'pipe', + stderr: 'pipe', + env: { PATH: process.env.PATH ?? '', NO_COLOR: '1' }, + }) + const timer = setTimeout(() => proc.kill(), 30_000) + const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) + clearTimeout(timer) + if (code !== 0) throw new ScaffoldError(`Scaffolder failed: ${stderr.trim().slice(0, 500) || `exit ${code}`}`) + + const files: ScaffoldFile[] = [] + for (const path of (await walk(target)).sort()) { + const full = join(target, path) + const mode = (await stat(full)).mode + files.push({ path: path.split('\\').join('/'), content: await readFile(full), executable: (mode & 0o111) !== 0 }) + } + if (input.kind === 'driver' && !files.some((f) => f.path === '.gitignore')) { + files.push({ + path: '.gitignore', + content: Buffer.from('/target\n/ui/node_modules\n/ui/dist\n'), + executable: false, + }) + } + return files + } finally { + await rm(work, { recursive: true, force: true }) + } +} + +/** Point the generated README at the bounty the repo was created for. */ +export function withBountyNote(files: ScaffoldFile[], note: string): ScaffoldFile[] { + return files.map((f) => + f.path === 'README.md' ? { ...f, content: Buffer.concat([f.content, Buffer.from(`\n${note}\n`)]) } : f, + ) +} diff --git a/apps/api/src/lib/scaffold/github.ts b/apps/api/src/lib/scaffold/github.ts new file mode 100644 index 00000000..82197843 --- /dev/null +++ b/apps/api/src/lib/scaffold/github.ts @@ -0,0 +1,120 @@ +import type { ProviderInstance } from '$lib/provider-instance' +import type { ScaffoldFile } from './generate' + +export class GithubScaffoldError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message) + } +} + +function apiBase(instance: ProviderInstance): string { + return instance.baseUrl === 'https://github.com' ? 'https://api.github.com' : `${instance.baseUrl}/api/v3` +} + +function client(instance: ProviderInstance, token: string) { + const base = apiBase(instance) + return async function gh(method: string, path: string, body?: unknown): Promise<{ data: T; res: Response }> { + const res = await fetch(`${base}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'tabularium/1.0', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await res.text() + const data = (text ? JSON.parse(text) : null) as T + if (!res.ok) { + const message = (data as { message?: string } | null)?.message ?? `GitHub returned ${res.status}` + throw new GithubScaffoldError(message, res.status) + } + return { data, res } + } +} + +/** + * Scopes granted to a classic OAuth token, from `X-OAuth-Scopes`. `null` when + * GitHub does not report them (e.g. GitHub App user tokens), in which case the + * push itself is the only check. + */ +export async function grantedScopes( + instance: ProviderInstance, + token: string, +): Promise<{ login: string; scopes: string[] | null }> { + const { data, res } = await client(instance, token)<{ login: string }>('GET', '/user') + const header = res.headers.get('x-oauth-scopes') + return { + login: data.login, + scopes: + header === null + ? null + : header + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + } +} + +export type CreatedRepo = { owner: string; name: string; fullName: string; htmlUrl: string; defaultBranch: string } + +/** + * Create a repository and push `files` as its single initial commit. The repo + * is created with an auto-generated first commit (the Git Data API cannot + * write to an empty repository), which the scaffold commit then replaces. + */ +export async function createRepoWithFiles( + instance: ProviderInstance, + token: string, + opts: { login: string; owner?: string; name: string; description: string; private: boolean; homepage?: string }, + files: ScaffoldFile[], + message: string, +): Promise { + const gh = client(instance, token) + const inOrg = opts.owner && opts.owner.toLowerCase() !== opts.login.toLowerCase() + const { data: repo } = await gh<{ + name: string + full_name: string + html_url: string + default_branch: string + owner: { login: string } + }>('POST', inOrg ? `/orgs/${encodeURIComponent(opts.owner!)}/repos` : '/user/repos', { + name: opts.name, + description: opts.description.slice(0, 350), + homepage: opts.homepage, + private: opts.private, + auto_init: true, + }) + const path = `/repos/${repo.owner.login}/${repo.name}` + + const tree = [] + for (const file of files) { + const { data: blob } = await gh<{ sha: string }>('POST', `${path}/git/blobs`, { + content: file.content.toString('base64'), + encoding: 'base64', + }) + tree.push({ path: file.path, mode: file.executable ? '100755' : '100644', type: 'blob', sha: blob.sha }) + } + const { data: newTree } = await gh<{ sha: string }>('POST', `${path}/git/trees`, { tree }) + const { data: commit } = await gh<{ sha: string }>('POST', `${path}/git/commits`, { + message, + tree: newTree.sha, + parents: [], + }) + await gh('PATCH', `${path}/git/refs/heads/${encodeURIComponent(repo.default_branch)}`, { + sha: commit.sha, + force: true, + }) + + return { + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + htmlUrl: repo.html_url, + defaultBranch: repo.default_branch, + } +} diff --git a/apps/api/src/lib/webhooks/events.ts b/apps/api/src/lib/webhooks/events.ts index e9a90a73..1c5a8649 100644 --- a/apps/api/src/lib/webhooks/events.ts +++ b/apps/api/src/lib/webhooks/events.ts @@ -21,6 +21,12 @@ export const WEBHOOK_EVENTS = [ 'transfer.rejected', 'transfer.cancelled', 'request.created', + 'request.approved', + 'request.rejected', + 'request.claimed', + 'request.unclaimed', + 'request.scaffolded', + 'request.fulfilled', ] as const export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] @@ -42,6 +48,12 @@ const META: Record = { 'transfer.rejected': { title: 'Ownership transfer rejected', color: 0xef4444 }, 'transfer.cancelled': { title: 'Ownership transfer cancelled', color: 0x64748b }, 'request.created': { title: 'Plugin requested', color: 0x64748b }, + 'request.approved': { title: 'Plugin request approved', color: 0x22c55e }, + 'request.rejected': { title: 'Plugin request rejected', color: 0xef4444 }, + '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.fulfilled': { title: 'Plugin request fulfilled', color: 0x22c55e }, } export type EventActor = { id: string | null; name: string | null } | null @@ -52,7 +64,16 @@ export type EventInput = { reason?: string | null release?: { version: string; tag?: string | null } transfer?: { id: string; fromUserId: string; toUserId: string; message?: string | null; status: string } - request?: { id: string; slug: string; name: string; description: string; kind?: string | null } + request?: { + id: string + slug: string + name: string + description: string + kind?: string | null + status?: string + claims?: number + repoUrl?: string | null + } } type UserRef = { id: string; displayName: string; username: string | null } @@ -86,7 +107,17 @@ export type EventContext = { owner?: UserRef release?: { version: string; tag: string | null; url: string } transfer?: { id: string; status: string; message: string | null; from: UserRef | null; to: UserRef | null } - request?: { id: string; slug: string; name: string; description: string; kind: string | null; url: string } + request?: { + id: string + slug: string + name: string + description: string + kind: string | null + status: string + claims: number | null + repoUrl: string | null + url: string + } } export function webBaseUrl(): string { @@ -163,7 +194,17 @@ export async function buildContext(type: WebhookEvent, input: EventInput): Promi if (input.request) { const r = input.request const url = `${base}/requests` - ctx.request = { id: r.id, slug: r.slug, name: r.name, description: r.description, kind: r.kind ?? null, url } + ctx.request = { + id: r.id, + slug: r.slug, + name: r.name, + description: r.description, + kind: r.kind ?? null, + status: r.status ?? 'pending', + claims: r.claims ?? null, + repoUrl: r.repoUrl ?? null, + url, + } ctx.subject = { kind: 'request', id: r.id, name: r.name, description: r.description, url } ctx.event.url = url } @@ -210,11 +251,17 @@ export async function emitEvent(type: WebhookEvent, input: EventInput = {}): Pro } } +const SAMPLE_REQUEST_STATUS: Partial> = { + 'request.created': 'pending', + 'request.rejected': 'rejected', + 'request.fulfilled': 'fulfilled', +} + // Sample context for previews and "send test" — real registry values, fake subject. export function sampleContext(type: WebhookEvent): EventContext { const base = webBaseUrl() const meta = META[type] - const isRequest = type === 'request.created' + const isRequest = type.startsWith('request.') const url = isRequest ? `${base}/requests` : `${base}/plugins/example-plugin` const owner = { id: '01EXAMPLEOWNER', displayName: 'Jane Doe', username: 'janedoe' } const ctx: EventContext = { @@ -244,6 +291,9 @@ export function sampleContext(type: WebhookEvent): EventContext { name: 'Example Plugin', description: 'A sample plugin used to preview webhook templates.', 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, url, } return ctx diff --git a/apps/api/src/lib/webhooks/presets.ts b/apps/api/src/lib/webhooks/presets.ts index db423df0..2312abd5 100644 --- a/apps/api/src/lib/webhooks/presets.ts +++ b/apps/api/src/lib/webhooks/presets.ts @@ -97,6 +97,18 @@ export const WEBHOOK_PRESETS: WebhookPreset[] = [ contentType: 'application/json', secrets: ['CONSUMER_KEY', 'CONSUMER_SECRET'], }, + { + key: 'deploy-hook', + label: 'Static site rebuild', + description: + 'Trigger a deploy hook (Vercel, Netlify, Cloudflare Pages…) so a static site that bakes in registry data rebuilds. Subscribe it to the request.* lifecycle events, not to votes.', + method: 'POST', + url: '{{secrets.DEPLOY_HOOK_URL | raw}}', + headers: [], + body: '{}', + contentType: 'application/json', + secrets: ['DEPLOY_HOOK_URL'], + }, { key: 'generic', label: 'Generic JSON', diff --git a/apps/api/src/routes/api/admin/plugins/[id].ts b/apps/api/src/routes/api/admin/plugins/[id].ts index a59b07bd..cc35f109 100644 --- a/apps/api/src/routes/api/admin/plugins/[id].ts +++ b/apps/api/src/routes/api/admin/plugins/[id].ts @@ -8,6 +8,7 @@ import { latestCacheKey } from '$routes/api/plugins/[slug]/latest' import { recordAudit } from '$lib/audit' import { jsonArrayOrNull } from '$lib/util' import { emitEvent } from '$lib/webhooks/events' +import { fulfillRequestForPlugin } from '$lib/request-fulfillment' const statusEnum = t.Union([t.Literal('approved'), t.Literal('pending'), t.Literal('rejected')]) @@ -91,6 +92,9 @@ export default new Elysia() actor: { id: admin.id, name: admin.displayName }, reason: body.status === 'rejected' ? (body.rejectionReason ?? null) : null, }) + if (body.status === 'approved') { + await fulfillRequestForPlugin(params.id, { id: admin.id, name: admin.displayName }) + } } return { ok: true, id: params.id } }, diff --git a/apps/api/src/routes/api/admin/plugins/bulk.ts b/apps/api/src/routes/api/admin/plugins/bulk.ts index 0637bfea..10514b42 100644 --- a/apps/api/src/routes/api/admin/plugins/bulk.ts +++ b/apps/api/src/routes/api/admin/plugins/bulk.ts @@ -7,6 +7,7 @@ import { cache } from '$lib/cache' import { latestCacheKey } from '$routes/api/plugins/[slug]/latest' import { recordAudit, actorFromAdmin } from '$lib/audit' import { emitEvent } from '$lib/webhooks/events' +import { fulfillRequestForPlugin } from '$lib/request-fulfillment' const actionEnum = t.Union([t.Literal('approve'), t.Literal('reject'), t.Literal('delete'), t.Literal('transfer')]) @@ -83,6 +84,9 @@ export default new Elysia().use(adminMiddleware).post( actor: { id: admin.id, name: admin.displayName }, reason: body.action === 'reject' ? (body.rejectionReason ?? null) : null, }) + if (body.action === 'approve') { + await fulfillRequestForPlugin(p.id, { id: admin.id, name: admin.displayName }) + } } } diff --git a/apps/api/src/routes/api/admin/requests/[id].ts b/apps/api/src/routes/api/admin/requests/[id].ts index 86d0b5ef..78b52f8a 100644 --- a/apps/api/src/routes/api/admin/requests/[id].ts +++ b/apps/api/src/routes/api/admin/requests/[id].ts @@ -4,41 +4,144 @@ import { adminMiddleware } from '$middleware/admin' import { db } from '$db' import { pluginRequests, pluginRequestVotes, pluginRequestClaims } from '$db/schema' import { recordAudit, actorFromAdmin } from '$lib/audit' +import { isKindKey } from '$lib/kinds' +import { bountySchema } from '$lib/request-bounty' +import { requestViewSchema, toRequestView } from '$lib/request-view' +import { emitEvent, type WebhookEvent } from '$lib/webhooks/events' -export default new Elysia().use(adminMiddleware).delete( - '/', - async ({ params, set, admin, request }) => { - const row = await db.query.pluginRequests.findFirst({ - where: { id: params.id }, - columns: { id: true, slug: true }, - }) - if (!row) { - set.status = 404 - return { error: 'Request not found' } - } - await db.delete(pluginRequestVotes).where(eq(pluginRequestVotes.requestId, params.id)) - await db.delete(pluginRequestClaims).where(eq(pluginRequestClaims.requestId, params.id)) - await db.delete(pluginRequests).where(eq(pluginRequests.id, params.id)) - await recordAudit({ - ...actorFromAdmin(admin, request), - action: 'request.delete', - target: `request:${row.slug}`, - meta: { id: row.id, slug: row.slug }, - }) - set.status = 204 - return null - }, - { - detail: { - tags: ['Admin'], - summary: 'Delete a plugin request', - operationId: 'adminDeleteRequest', - security: [{ bearerAuth: [] }, { cookieAuth: [] }], +const errorSchema = t.Object({ error: t.String() }) + +const AUDIT_ACTION = { + pending: 'request.reopen', + approved: 'request.approve', + rejected: 'request.reject', + fulfilled: 'request.fulfill', +} as const + +export default new Elysia() + .use(adminMiddleware) + .patch( + '/', + async ({ params, body, set, admin, request }) => { + const row = await db.query.pluginRequests.findFirst({ where: { id: params.id } }) + if (!row) { + set.status = 404 + return { error: 'Request not found' } + } + if (body.kind !== undefined && body.kind !== null && !isKindKey(body.kind)) { + set.status = 400 + return { error: `Unknown plugin kind '${body.kind}'.` } + } + + const now = Date.now() + const patch: Partial = { updatedAt: now } + if (body.name !== undefined) patch.name = body.name + if (body.description !== undefined) patch.description = body.description + if (body.kind !== undefined) patch.kind = body.kind + if (body.bounty !== undefined) patch.bounty = body.bounty === null ? null : JSON.stringify(body.bounty) + + let event: WebhookEvent | null = null + if (body.status !== undefined && body.status !== row.status) { + patch.status = body.status + patch.reviewedBy = admin.id + patch.reviewedAt = now + patch.rejectionReason = body.status === 'rejected' ? (body.rejectionReason ?? null) : null + if (body.status === 'fulfilled') { + const pluginId = body.fulfilledPluginId ?? row.slug + const plugin = await db.query.plugins.findFirst({ where: { id: pluginId }, columns: { id: true } }) + if (!plugin) { + set.status = 400 + return { error: `No plugin '${pluginId}' to link the request to.` } + } + patch.fulfilledPluginId = plugin.id + patch.fulfilledAt = now + } else { + patch.fulfilledPluginId = null + patch.fulfilledAt = null + } + if (body.status === 'approved') event = 'request.approved' + else if (body.status === 'rejected') event = 'request.rejected' + else if (body.status === 'fulfilled') event = 'request.fulfilled' + } else if (body.status === 'rejected' && body.rejectionReason !== undefined) { + patch.rejectionReason = body.rejectionReason + } + + await db.update(pluginRequests).set(patch).where(eq(pluginRequests.id, params.id)) + const updated = (await db.query.pluginRequests.findFirst({ where: { id: params.id } }))! + await recordAudit({ + ...actorFromAdmin(admin, request), + action: patch.status ? AUDIT_ACTION[patch.status] : 'request.update', + target: `request:${updated.slug}`, + meta: { id: updated.id, fields: Object.keys(body) }, + }) + if (event) { + await emitEvent(event, { + pluginId: updated.fulfilledPluginId ?? undefined, + actor: { id: admin.id, name: admin.displayName }, + reason: event === 'request.rejected' ? (updated.rejectionReason ?? null) : null, + request: updated, + }) + } + return toRequestView(updated, admin.id) + }, + { + detail: { + tags: ['Admin'], + summary: 'Review or edit a plugin request', + description: + 'Approve, reject (with an optional reason), reopen as pending or mark fulfilled a request, and edit its name, description, kind and bounty board metadata. Status changes emit `request.approved`, `request.rejected` or `request.fulfilled`. `fulfilledPluginId` defaults to the request slug.', + operationId: 'adminUpdateRequest', + security: [{ bearerAuth: [] }, { cookieAuth: [] }], + }, + params: t.Object({ id: t.String() }), + body: t.Object({ + status: t.Optional( + t.Union([t.Literal('pending'), t.Literal('approved'), t.Literal('rejected'), t.Literal('fulfilled')]), + ), + rejectionReason: t.Optional(t.Nullable(t.String({ maxLength: 500 }))), + fulfilledPluginId: t.Optional(t.String({ maxLength: 80 })), + name: t.Optional(t.String({ minLength: 1, maxLength: 120 })), + description: t.Optional(t.String({ minLength: 1, maxLength: 2000 })), + kind: t.Optional(t.Nullable(t.String({ maxLength: 40 }))), + bounty: t.Optional(t.Nullable(bountySchema)), + }), + response: { 200: requestViewSchema, 400: errorSchema, 404: errorSchema }, + }, + ) + .delete( + '/', + async ({ params, set, admin, request }) => { + const row = await db.query.pluginRequests.findFirst({ + where: { id: params.id }, + columns: { id: true, slug: true }, + }) + if (!row) { + set.status = 404 + return { error: 'Request not found' } + } + await db.delete(pluginRequestVotes).where(eq(pluginRequestVotes.requestId, params.id)) + await db.delete(pluginRequestClaims).where(eq(pluginRequestClaims.requestId, params.id)) + await db.delete(pluginRequests).where(eq(pluginRequests.id, params.id)) + await recordAudit({ + ...actorFromAdmin(admin, request), + action: 'request.delete', + target: `request:${row.slug}`, + meta: { id: row.id, slug: row.slug }, + }) + set.status = 204 + return null }, - params: t.Object({ id: t.String() }), - response: { - 204: t.Null(), - 404: t.Object({ error: t.String() }), + { + detail: { + tags: ['Admin'], + summary: 'Delete a plugin request', + operationId: 'adminDeleteRequest', + security: [{ bearerAuth: [] }, { cookieAuth: [] }], + }, + params: t.Object({ id: t.String() }), + response: { + 204: t.Null(), + 404: errorSchema, + }, }, - }, -) + ) diff --git a/apps/api/src/routes/api/requests/[id]/claim.ts b/apps/api/src/routes/api/requests/[id]/claim.ts index 522c18a0..b421eced 100644 --- a/apps/api/src/routes/api/requests/[id]/claim.ts +++ b/apps/api/src/routes/api/requests/[id]/claim.ts @@ -2,7 +2,9 @@ import { Elysia, t } from 'elysia' import { authMiddleware } from '$middleware/auth' import { rateLimit } from '$middleware/rate-limit' import { db } from '$db' -import { claimRequest, unclaimRequest, claimCount, hasClaimedRequest } from '$lib/claims' +import { claimRequest, unclaimRequest, hasClaimedRequest } from '$lib/claims' +import { claimerSchema, loadClaimers } from '$lib/request-view' +import { emitEvent } from '$lib/webhooks/events' export default new Elysia() .use(authMiddleware) @@ -10,35 +12,49 @@ export default new Elysia() .post( '/', async ({ params, user, set }) => { - const request = await db.query.pluginRequests.findFirst({ - where: { id: params.id }, - columns: { id: true }, - }) + const request = await db.query.pluginRequests.findFirst({ where: { id: params.id } }) if (!request) { set.status = 404 return { error: 'Request not found' } } const had = await hasClaimedRequest(params.id, user.sub) + // Releasing stays possible whatever the status; new claims only make + // sense on an open bounty. + if (!had && request.status !== 'approved') { + set.status = 409 + return { + error: + request.status === 'fulfilled' + ? 'This request has already shipped.' + : 'Only approved requests can be claimed.', + } + } if (had) { await unclaimRequest(params.id, user.sub) } else { await claimRequest(params.id, user.sub) } - return { claimed: !had, claims: await claimCount(params.id) } + const claimers = (await loadClaimers([params.id])).get(params.id) ?? [] + await emitEvent(had ? 'request.unclaimed' : 'request.claimed', { + actor: { id: user.sub, name: user.username ?? null }, + request: { ...request, claims: claimers.length }, + }) + return { claimed: !had, claims: claimers.length, claimers } }, { detail: { tags: ['Requests'], summary: 'Toggle a claim on a plugin request', description: - "Adds or removes the current user's claim on the request. Claims are non-exclusive — multiple users can claim the same request. They do not block plugin submission.", + "Adds or removes the current user's claim on the request. Claims are non-exclusive — multiple users can claim the same request, and a claimed request shows as in progress until a plugin with its slug is approved. Only `approved` requests can be claimed; a claim can always be released.", operationId: 'toggleRequestClaim', security: [{ bearerAuth: [] }, { cookieAuth: [] }], }, params: t.Object({ id: t.String() }), response: { - 200: t.Object({ claimed: t.Boolean(), claims: t.Number() }), + 200: t.Object({ claimed: t.Boolean(), claims: t.Number(), claimers: t.Array(claimerSchema) }), 404: t.Object({ error: t.String() }), + 409: t.Object({ error: t.String() }), }, }, ) diff --git a/apps/api/src/routes/api/requests/[id]/index.ts b/apps/api/src/routes/api/requests/[id]/index.ts new file mode 100644 index 00000000..97e4b66c --- /dev/null +++ b/apps/api/src/routes/api/requests/[id]/index.ts @@ -0,0 +1,40 @@ +import { Elysia, t } from 'elysia' +import { db } from '$db' +import { resolveOptionalViewer } from '$lib/optional-viewer' +import { PUBLIC_REQUEST_STATUSES } from '$lib/request-bounty' +import { requestViewSchema, toRequestView } from '$lib/request-view' + +export default new Elysia().get( + '/', + async ({ params, headers, cookie, request, set }) => { + const row = + (await db.query.pluginRequests.findFirst({ where: { id: params.id } })) ?? + (await db.query.pluginRequests.findFirst({ where: { slug: params.id } })) + const viewer = await resolveOptionalViewer(request, headers, cookie) + if (row && !PUBLIC_REQUEST_STATUSES.includes(row.status) && row.requesterId !== viewer?.sub) { + const role = viewer + ? (await db.query.users.findFirst({ where: { id: viewer.sub }, columns: { role: true } }))?.role + : undefined + if (role !== 'admin') { + set.status = 404 + return { error: 'Request not found' } + } + } + if (!row) { + set.status = 404 + return { error: 'Request not found' } + } + return toRequestView(row, viewer?.sub ?? null) + }, + { + detail: { + tags: ['Requests'], + summary: 'Get a plugin request', + description: + 'One request by id or slug, with bounty metadata, claimers and the fulfilling plugin. Public for `approved` and `fulfilled` requests; `pending` and `rejected` ones are visible to admins and their requester only.', + operationId: 'getRequest', + }, + params: t.Object({ id: t.String({ description: 'Request id or slug.' }) }), + response: { 200: requestViewSchema, 404: t.Object({ error: t.String() }) }, + }, +) diff --git a/apps/api/src/routes/api/requests/[id]/scaffold.ts b/apps/api/src/routes/api/requests/[id]/scaffold.ts new file mode 100644 index 00000000..e514e765 --- /dev/null +++ b/apps/api/src/routes/api/requests/[id]/scaffold.ts @@ -0,0 +1,247 @@ +import { Elysia, t } from 'elysia' +import { and, eq } from 'drizzle-orm' +import semver from 'semver' +import { authMiddleware } from '$middleware/auth' +import { rateLimit } from '$middleware/rate-limit' +import { db } from '$db' +import { pluginRequestClaims } from '$db/schema' +import { getBranding } from '$lib/branding' +import { hasClaimedRequest } from '$lib/claims' +import { logger } from '$lib/logger' +import { getValidAccessToken, OAuthExpiredError } from '$lib/oauth-tokens' +import { getInstance } from '$lib/provider-instance' +import { claimerSchema, loadClaimers } from '$lib/request-view' +import { generatePluginFiles, isScaffoldKind, ScaffoldError, withBountyNote } from '$lib/scaffold/generate' +import { createRepoWithFiles, grantedScopes, GithubScaffoldError } from '$lib/scaffold/github' +import { emitEvent, webBaseUrl } from '$lib/webhooks/events' + +const log = logger.child({ module: 'request-scaffold' }) + +const errorSchema = t.Object({ + error: t.String(), + reauthUrl: t.Optional(t.String()), + missingScope: t.Optional(t.String()), + hint: t.Optional(t.String()), +}) + +// Re-authorize with the extra scope GitHub requires to write workflow files, +// then land back where the user started. +function reauthUrl(instanceId: string, returnTo: string | undefined, scope?: string): string { + const params = new URLSearchParams() + if (scope) params.set('scope', scope) + params.set('return_to', returnTo ?? `${webBaseUrl()}/requests`) + return `${webBaseUrl()}/auth/${encodeURIComponent(instanceId)}?${params}` +} + +function localCommand(slug: string, kind: string): string { + return kind === 'theme' + ? `npx --package @tabularis/create-plugin tabularis-theme scaffold ${slug} --dir ${slug} --min-runtime-version ` + : `npm create @tabularis/plugin@latest ${slug}` +} + +export default new Elysia() + .use(authMiddleware) + .use(rateLimit({ bucket: 'request-scaffold', limit: 5, 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 (request.status !== 'approved') { + set.status = 409 + return { error: 'Only approved requests can be scaffolded.' } + } + if (!(await hasClaimedRequest(request.id, user.sub))) { + set.status = 409 + return { error: 'Claim this request before creating its repository.' } + } + const kind = request.kind + if (!isScaffoldKind(kind)) { + set.status = 422 + return { error: `There is no scaffold for '${kind ?? 'unspecified'}' plugins yet.` } + } + if (kind === 'theme' && !semver.valid(body.theme?.minRuntimeVersion ?? '')) { + set.status = 400 + return { error: 'Themes need `theme.minRuntimeVersion`, the first Tabularis version that supports them.' } + } + + const candidates = await db.query.identities.findMany({ where: { userId: user.sub } }) + const identity = candidates.find( + (i) => (!body.identityId || i.id === body.identityId) && getInstance(i.providerInstanceId)?.kind === 'github', + ) + const instance = identity ? getInstance(identity.providerInstanceId) : undefined + if (!identity || !instance) { + set.status = 412 + return { + error: 'Creating the repository needs a linked GitHub account.', + hint: `Scaffold it locally instead: ${localCommand(request.slug, kind)}`, + } + } + + let token: string + try { + token = await getValidAccessToken(identity, instance) + } catch (err) { + if (!(err instanceof OAuthExpiredError)) throw err + set.status = 401 + return { + error: `Your ${instance.displayName} session expired.`, + reauthUrl: reauthUrl(instance.id, body.returnTo), + } + } + + let login: string + try { + const granted = await grantedScopes(instance, token) + login = granted.login + if (granted.scopes && !granted.scopes.includes('workflow') && !granted.scopes.includes('repo')) { + set.status = 403 + return { + error: `${instance.displayName} only lets apps add CI workflows with the \`workflow\` permission. Grant it once to create the repository.`, + missingScope: 'workflow', + reauthUrl: reauthUrl(instance.id, body.returnTo, 'workflow'), + } + } + } catch (err) { + if (err instanceof GithubScaffoldError && err.status === 401) { + set.status = 401 + return { + error: `Your ${instance.displayName} session expired.`, + reauthUrl: reauthUrl(instance.id, body.returnTo), + } + } + throw err + } + + let files + try { + files = await generatePluginFiles( + kind === 'theme' + ? { kind, slug: request.slug, minRuntimeVersion: body.theme!.minRuntimeVersion } + : { + kind, + slug: request.slug, + driver: { + dbType: body.driver?.dbType ?? 'network', + quote: body.driver?.quote ?? '"', + withUi: body.driver?.withUi ?? false, + }, + }, + ) + } catch (err) { + if (!(err instanceof ScaffoldError)) throw err + set.status = 422 + return { error: err.message, hint: `Scaffold it locally instead: ${localCommand(request.slug, kind)}` } + } + + const requestUrl = `${webBaseUrl()}/requests` + files = withBountyNote( + files, + `## Bounty\n\nScaffolded for the [${request.name}](${requestUrl}) request on ${getBranding().name}. Publish a release, then submit this repository to the registry: the request ships as soon as the plugin \`${request.slug}\` is listed.`, + ) + + let repo + try { + repo = await createRepoWithFiles( + instance, + token, + { + login, + owner: body.owner, + name: body.repoName ?? request.slug, + description: request.description, + private: body.private ?? false, + }, + files, + `Scaffold ${request.slug} with @tabularis/create-plugin`, + ) + } catch (err) { + if (!(err instanceof GithubScaffoldError)) throw err + log.warn({ err: err.message, status: err.status, requestId: request.id }, 'repository creation failed') + if (err.status === 422) { + set.status = 409 + return { error: `Could not create the repository: ${err.message}. Pick another name.` } + } + set.status = 502 + return { error: `${instance.displayName} refused the repository: ${err.message}` } + } + + await db + .update(pluginRequestClaims) + .set({ repoUrl: repo.htmlUrl, scaffoldedAt: Date.now() }) + .where(and(eq(pluginRequestClaims.requestId, request.id), eq(pluginRequestClaims.userId, user.sub))) + + const claimers = (await loadClaimers([request.id])).get(request.id) ?? [] + await emitEvent('request.scaffolded', { + actor: { id: user.sub, name: user.username ?? null }, + request: { ...request, claims: claimers.length, repoUrl: repo.htmlUrl }, + }) + + return { + repoUrl: repo.htmlUrl, + fullName: repo.fullName, + defaultBranch: repo.defaultBranch, + files: files.length, + submitUrl: `${webBaseUrl()}/submit?repo=${encodeURIComponent(repo.htmlUrl)}`, + claimers, + } + }, + { + detail: { + tags: ['Requests'], + summary: 'Create a plugin repository for a claimed request', + description: + "Generates the project with @tabularis/create-plugin (the request slug becomes the plugin name, so the request ships when that plugin is listed) and pushes it as the first commit of a new repository in the caller's GitHub account or one of their orgs. Requires an active claim on an approved `driver` or `theme` request. GitHub only accepts CI workflow files from tokens with the `workflow` scope: without it the call returns 403 with a `reauthUrl` that grants it and comes back to `returnTo`.", + operationId: 'scaffoldRequest', + security: [{ bearerAuth: [] }, { cookieAuth: [] }], + }, + params: t.Object({ id: t.String() }), + body: t.Object({ + identityId: t.Optional(t.String({ description: 'Linked GitHub identity to use. Default: the first one.' })), + owner: t.Optional( + t.String({ pattern: '^[A-Za-z0-9-]{1,39}$', description: 'Org to create the repo in. Default: the user.' }), + ), + repoName: t.Optional( + t.String({ pattern: '^[A-Za-z0-9._-]{1,100}$', description: 'Repository name. Default: the request slug.' }), + ), + private: t.Optional(t.Boolean({ description: 'Create a private repository. Default false.' })), + driver: t.Optional( + t.Object({ + dbType: t.Optional( + t.Union([t.Literal('network'), t.Literal('file'), t.Literal('folder'), t.Literal('api')]), + ), + quote: t.Optional(t.Union([t.Literal('"'), t.Literal('`')])), + withUi: t.Optional(t.Boolean()), + }), + ), + theme: t.Optional(t.Object({ minRuntimeVersion: t.String({ maxLength: 40 }) })), + 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.String(), + fullName: t.String(), + defaultBranch: t.String(), + files: t.Number(), + submitUrl: t.String(), + claimers: t.Array(claimerSchema), + }), + 400: errorSchema, + 401: errorSchema, + 403: errorSchema, + 404: errorSchema, + 409: errorSchema, + 412: errorSchema, + 422: errorSchema, + 502: errorSchema, + }, + }, + ) diff --git a/apps/api/src/routes/api/requests/[id]/upvote.ts b/apps/api/src/routes/api/requests/[id]/upvote.ts index 86d8b916..34555a3e 100644 --- a/apps/api/src/routes/api/requests/[id]/upvote.ts +++ b/apps/api/src/routes/api/requests/[id]/upvote.ts @@ -21,6 +21,10 @@ export default new Elysia() set.status = 404 return { error: 'Request not found' } } + if (request.status !== 'approved') { + set.status = 409 + return { error: 'Only approved requests can be upvoted.' } + } try { return await toggleVote(user.sub, params.id) @@ -36,7 +40,7 @@ export default new Elysia() summary: 'Toggle upvote on a request', description: "Toggles the current user's vote on the request: if not yet voted, adds a vote; if already voted, removes it. " + - "Returns the new total and the user's new vote state. Requires auth.", + "Returns the new total and the user's new vote state. Requires auth. Only `approved` requests take votes.", operationId: 'toggleUpvote', security: [{ bearerAuth: [] }, { cookieAuth: [] }], }, diff --git a/apps/api/src/routes/api/requests/index.ts b/apps/api/src/routes/api/requests/index.ts index 5fe62943..79c26f4e 100644 --- a/apps/api/src/routes/api/requests/index.ts +++ b/apps/api/src/routes/api/requests/index.ts @@ -3,13 +3,15 @@ import { ulid } from 'ulid' import { authMiddleware } from '$middleware/auth' import { rateLimit } from '$middleware/rate-limit' import { db } from '$db' -import { pluginRequests, pluginRequestClaims } from '$db/schema' -import { desc, count, eq, and, inArray } from 'drizzle-orm' +import { pluginRequests } from '$db/schema' +import { desc, count, eq, and, or, inArray, type SQL } from 'drizzle-orm' import { getFeatures } from '$lib/features' -import { verifySessionToken, delegatedAccess } from '$lib/access' import { getKinds, isKindKey } from '$lib/kinds' import { deriveSlug } from '$lib/slug' import { emitEvent } from '$lib/webhooks/events' +import { resolveOptionalViewer } from '$lib/optional-viewer' +import { isRequestStatus, PUBLIC_REQUEST_STATUSES, type RequestStatus } from '$lib/request-bounty' +import { requestViewSchema, toRequestViews } from '$lib/request-view' // Same normalisation submissions apply to repo names, so a request for // "Awesome Plugin" is closed automatically when `awesome` gets published. @@ -22,24 +24,11 @@ function slugFromName(name: string): string { ).slice(0, 80) } -const requestSchema = t.Object({ - id: t.String(), - slug: t.String(), - name: t.String(), - description: t.String(), - kind: t.Nullable(t.String()), - requesterId: t.String(), - upvotes: t.Number(), - createdAt: t.Number(), - claims: t.Number(), - claimedByMe: t.Boolean(), -}) - const requestListResponseSchema = t.Object({ total: t.Number(), page: t.Number(), limit: t.Number(), - requests: t.Array(requestSchema), + requests: t.Array(requestViewSchema), }) const createRequestResponseSchema = t.Object({ @@ -49,6 +38,7 @@ const createRequestResponseSchema = t.Object({ description: t.String(), kind: t.Nullable(t.String()), requesterId: t.String(), + status: t.String(), }) const errorSchema = t.Object({ error: t.String() }) @@ -59,18 +49,28 @@ function clampInt(raw: unknown, def: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.trunc(n))) } -async function resolveOptionalViewer( - headers: Record, - cookie: Record, -): Promise<{ sub: string } | null> { - const authHeader = headers.authorization - const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined - const raw = cookie.auth?.value - const cookieToken = typeof raw === 'string' ? raw : undefined - const token = bearerToken ?? cookieToken - if (!token) return null - const payload = await verifySessionToken(token) - return payload ? { sub: payload.sub } : null +function parseStatuses(raw: string | undefined): RequestStatus[] { + if (!raw) return PUBLIC_REQUEST_STATUSES + const list = [...new Set(raw.split(',').map((s) => s.trim()))].filter(isRequestStatus) + return list.length > 0 ? list : PUBLIC_REQUEST_STATUSES +} + +// Public statuses are visible to everyone; pending and rejected ones only to +// admins, and to the requester for their own requests. +async function visibilityFilter(statuses: RequestStatus[], viewerId: string | null): Promise { + const isAdmin = viewerId + ? (await db.query.users.findFirst({ where: { id: viewerId }, columns: { role: true } }))?.role === 'admin' + : false + if (isAdmin) return inArray(pluginRequests.status, statuses) + const open = statuses.filter((s) => PUBLIC_REQUEST_STATUSES.includes(s)) + const own = statuses.filter((s) => !PUBLIC_REQUEST_STATUSES.includes(s)) + const parts: SQL[] = [] + if (open.length > 0) parts.push(inArray(pluginRequests.status, open)) + if (own.length > 0 && viewerId) { + parts.push(and(inArray(pluginRequests.status, own), eq(pluginRequests.requesterId, viewerId)) as SQL) + } + if (parts.length === 0) return eq(pluginRequests.id, '') + return parts.length === 1 ? parts[0] : or(...parts) } export default new Elysia() @@ -81,50 +81,27 @@ export default new Elysia() const limit = clampInt(query.limit, 20, 1, 100) const offset = (page - 1) * limit const sort = query.sort === 'recent' ? pluginRequests.createdAt : pluginRequests.upvotes - const where = query.kind ? eq(pluginRequests.kind, query.kind) : undefined + const viewer = await resolveOptionalViewer(request, headers, cookie) + const visible = await visibilityFilter(parseStatuses(query.status), viewer?.sub ?? null) + const where = query.kind ? and(visible, eq(pluginRequests.kind, query.kind)) : visible const [{ total }] = await db.select({ total: count() }).from(pluginRequests).where(where) + const rows = await db + .select() + .from(pluginRequests) + .where(where) + .orderBy(desc(sort), desc(pluginRequests.createdAt)) + .limit(limit) + .offset(offset) - const rows = await db.select().from(pluginRequests).where(where).orderBy(desc(sort)).limit(limit).offset(offset) - - const viewer = delegatedAccess(request)?.user ?? (await resolveOptionalViewer(headers, cookie)) - const ids = rows.map((r) => r.id) - const claimRows = - ids.length === 0 - ? [] - : await db - .select({ requestId: pluginRequestClaims.requestId, n: count() }) - .from(pluginRequestClaims) - .where(inArray(pluginRequestClaims.requestId, ids)) - .groupBy(pluginRequestClaims.requestId) - const claimsByRequest = new Map(claimRows.map((r) => [r.requestId, r.n])) - - let myClaimSet = new Set() - if (viewer && ids.length > 0) { - const mine = await db - .select({ requestId: pluginRequestClaims.requestId }) - .from(pluginRequestClaims) - .where(and(eq(pluginRequestClaims.userId, viewer.sub), inArray(pluginRequestClaims.requestId, ids))) - myClaimSet = new Set(mine.map((r) => r.requestId)) - } - - return { - total, - page, - limit, - requests: rows.map((r) => ({ - ...r, - claims: claimsByRequest.get(r.id) ?? 0, - claimedByMe: myClaimSet.has(r.id), - })), - } + return { total, page, limit, requests: await toRequestViews(rows, viewer?.sub ?? null) } }, { detail: { tags: ['Requests'], summary: 'List plugin requests', description: - 'Browse the community wishlist of plugins users would like to see. Sorted by upvotes by default. Public — no auth required.', + 'Browse the community wishlist of plugins users would like to see, with bounty metadata, claimers and the fulfilling plugin. Sorted by upvotes by default. Public — no auth required; with a session, `claimedByMe` and `votedByMe` are filled in. Only `approved` and `fulfilled` requests are public: `pending` and `rejected` ones are returned to admins, and to their requester.', operationId: 'listRequests', }, query: t.Object({ @@ -136,6 +113,12 @@ export default new Elysia() }), ), kind: t.Optional(t.String({ description: 'Only requests for this plugin kind key (see `GET /api/kinds`).' })), + status: t.Optional( + t.String({ + description: + 'Comma-separated statuses: `pending`, `approved`, `rejected`, `fulfilled`. Default `approved,fulfilled`.', + }), + ), }), response: { 200: requestListResponseSchema }, }, @@ -179,6 +162,7 @@ export default new Elysia() description: body.description, kind, requesterId: user.sub, + status: 'pending' as const, } await db.insert(pluginRequests).values(request) await emitEvent('request.created', { @@ -192,7 +176,7 @@ export default new Elysia() tags: ['Requests'], summary: 'Create plugin request', description: - 'Add a new entry to the community wishlist. Requires auth. `slug` is optional and derived from `name` when omitted; it must be unique across requests. `kind` must match a configured plugin kind key, and is required whenever the instance defines kinds.', + 'Add a new entry to the community wishlist. Requires auth. New requests are `pending` until an admin approves them. `slug` is optional and derived from `name` when omitted; it must be unique across requests. `kind` must match a configured plugin kind key, and is required whenever the instance defines kinds.', operationId: 'createRequest', security: [{ bearerAuth: [] }, { cookieAuth: [] }], }, diff --git a/apps/api/src/routes/api/submit/oauth.ts b/apps/api/src/routes/api/submit/oauth.ts index 74f0e96c..dfc4297f 100644 --- a/apps/api/src/routes/api/submit/oauth.ts +++ b/apps/api/src/routes/api/submit/oauth.ts @@ -1,9 +1,8 @@ import { Elysia, t } from 'elysia' import { authMiddleware } from '$middleware/auth' import { rateLimit } from '$middleware/rate-limit' -import { eq } from 'drizzle-orm' import { db } from '$db' -import { plugins, pluginRequests, pluginRequestVotes, pluginRequestClaims } from '$db/schema' +import { plugins } from '$db/schema' import { deriveSlug } from '$lib/slug' import { parseRepoUrl, checkOwnership } from '$lib/providers' import { setupWebhook } from '$lib/setup-webhook' @@ -186,20 +185,6 @@ export default new Elysia() } } - try { - const matching = await db.query.pluginRequests.findFirst({ - where: { slug }, - columns: { id: true }, - }) - if (matching) { - await db.delete(pluginRequestVotes).where(eq(pluginRequestVotes.requestId, matching.id)) - await db.delete(pluginRequestClaims).where(eq(pluginRequestClaims.requestId, matching.id)) - await db.delete(pluginRequests).where(eq(pluginRequests.id, matching.id)) - } - } catch (err) { - log.warn({ err, slug }, 'failed to clean up matching plugin request') - } - await emitEvent('plugin.submitted', { pluginId: slug, actor: { id: user.sub, name: identity.username } }) const webhookUrl = `${env.BASE_URL}/api/webhooks/release` diff --git a/apps/api/src/routes/auth/[instance]/callback.ts b/apps/api/src/routes/auth/[instance]/callback.ts index 6e469f4c..2227bf5f 100644 --- a/apps/api/src/routes/auth/[instance]/callback.ts +++ b/apps/api/src/routes/auth/[instance]/callback.ts @@ -12,7 +12,7 @@ import { env, isProd } from '$lib/env' import { logger } from '$lib/logger' import { getSetting, setSetting, isSettingsInitialized } from '$lib/settings' import { recordAudit } from '$lib/audit' -import { safeReturnTo } from './index' +import { safeReturnTo } from '$lib/return-to' const log = logger.child({ module: 'auth-callback' }) diff --git a/apps/api/src/routes/auth/[instance]/index.ts b/apps/api/src/routes/auth/[instance]/index.ts index c9281650..351e3e25 100644 --- a/apps/api/src/routes/auth/[instance]/index.ts +++ b/apps/api/src/routes/auth/[instance]/index.ts @@ -2,24 +2,8 @@ import { Elysia, t } from 'elysia' import { GitHub, GitLab, Gitea, generateState, generateCodeVerifier } from 'arctic' import { verifyJwt } from '$lib/jwt' import { getInstance, type ProviderInstance } from '$lib/provider-instance' -import { env, isProd, allowedOrigins } from '$lib/env' - -export function safeReturnTo(raw: string | undefined): string | null { - if (!raw || typeof raw !== 'string' || raw.length > 512) return null - try { - const base = env.WEB_BASE_URL ?? env.BASE_URL - const baseOrigin = new URL(base).origin - const target = new URL(raw, base) - if (target.origin === baseOrigin) return target.pathname + target.search + target.hash - // Sites trusted for CORS (e.g. a project website hosting the request form) - // may also get the user back after sign-in; they keep their full URL. - if ((target.protocol === 'https:' || target.protocol === 'http:') && allowedOrigins().includes(target.origin)) - return target.href - return null - } catch { - return null - } -} +import { env, isProd } from '$lib/env' +import { safeReturnTo } from '$lib/return-to' async function s256Challenge(verifier: string): Promise { const data = new TextEncoder().encode(verifier) @@ -30,16 +14,22 @@ async function s256Challenge(verifier: string): Promise { return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +// Scopes a feature may ask for on top of the sign-in set, only when it needs +// them (e.g. pushing CI workflow files when scaffolding a repository). +const OPTIONAL_GITHUB_SCOPES = ['workflow'] + async function buildAuthUrl( inst: ProviderInstance, state: string, linking: boolean, + extraScopes: string[] = [], ): Promise<{ url: URL; codeVerifier?: string }> { const callback = `${env.BASE_URL}/auth/${inst.id}/callback` if (inst.kind === 'github') { const gh = new GitHub(inst.clientId, inst.clientSecret, callback) - const url = gh.createAuthorizationURL(state, ['read:user', 'read:org', 'public_repo', 'admin:repo_hook']) + const extra = extraScopes.filter((s) => OPTIONAL_GITHUB_SCOPES.includes(s)) + const url = gh.createAuthorizationURL(state, ['read:user', 'read:org', 'public_repo', 'admin:repo_hook', ...extra]) if (linking) url.searchParams.set('prompt', 'consent') if (inst.baseUrl === 'https://github.com') { const codeVerifier = generateCodeVerifier() @@ -89,7 +79,8 @@ export default new Elysia().get( const state = generateState() const returnTo = safeReturnTo(query.return_to) - const { url, codeVerifier } = await buildAuthUrl(inst, state, linking) + const extraScopes = query.scope ? query.scope.split(/[\s,]+/).filter(Boolean) : [] + const { url, codeVerifier } = await buildAuthUrl(inst, state, linking, extraScopes) cookie.oauth_state.set({ value: { nonce: state, instanceId: inst.id, codeVerifier, linking, returnTo }, @@ -107,13 +98,14 @@ export default new Elysia().get( tags: ['Auth'], summary: 'Start OAuth flow for a provider instance', description: - "Redirects to the provider instance's authorize page. Instance IDs are configured by the admin (e.g. `github`, `gitlab`, `forgejo-acme`). Set `link=1` to attach this identity to the currently signed-in user.", + "Redirects to the provider instance's authorize page. Instance IDs are configured by the admin (e.g. `github`, `gitlab`, `forgejo-acme`). Set `link=1` to attach this identity to the currently signed-in user. `scope=workflow` additionally asks GitHub for the `workflow` scope (needed to create scaffolded repositories with CI); other values are ignored.", operationId: 'startOAuth', }, params: t.Object({ instance: t.String() }), query: t.Object({ link: t.Optional(t.String()), return_to: t.Optional(t.String()), + scope: t.Optional(t.String({ maxLength: 100 })), }), }, ) diff --git a/apps/api/src/routes/auth/return.ts b/apps/api/src/routes/auth/return.ts new file mode 100644 index 00000000..cfa237f0 --- /dev/null +++ b/apps/api/src/routes/auth/return.ts @@ -0,0 +1,24 @@ +import { Elysia, t } from 'elysia' +import { safeReturnTo } from '$lib/return-to' +import { webBaseUrl } from '$lib/webhooks/events' + +// Lets the web app send an already signed-in user back to a trusted external +// site (it cannot check ALLOWED_ORIGINS itself). Untrusted targets go home. +export default new Elysia().get( + '/', + ({ query, redirect }) => { + const target = safeReturnTo(query.to) + if (!target) return redirect(`${webBaseUrl()}/`, 302) + return redirect(target.startsWith('/') ? `${webBaseUrl()}${target}` : target, 302) + }, + { + detail: { + tags: ['Auth'], + summary: 'Redirect to a trusted return URL', + description: + 'Redirects to `to` when it is a path on the web app or a URL on an allowed origin (`ALLOWED_ORIGINS`), otherwise to the home page. Used after sign-in when the user is already authenticated.', + operationId: 'authReturn', + }, + query: t.Object({ to: t.Optional(t.String({ maxLength: 512 })) }), + }, +) diff --git a/apps/api/tests/lib/safe-return-to.test.ts b/apps/api/tests/lib/safe-return-to.test.ts index 83979bba..5b91c692 100644 --- a/apps/api/tests/lib/safe-return-to.test.ts +++ b/apps/api/tests/lib/safe-return-to.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from 'bun:test' import { env } from '$lib/env' -import { safeReturnTo } from '../../src/routes/auth/[instance]/index' +import { safeReturnTo } from '../../src/lib/return-to' const original = env.ALLOWED_ORIGINS @@ -27,3 +27,17 @@ test('returns the full URL for an ALLOWED_ORIGINS site', () => { ) expect(safeReturnTo('https://tabularis.dev.evil.example/')).toBeNull() }) + +test('GET /auth/return follows trusted URLs and sends anything else home', async () => { + const { buildApp } = await import('../helpers') + const { webBaseUrl } = await import('../../src/lib/webhooks/events') + env.ALLOWED_ORIGINS = 'https://tabularis.dev' + const app = await buildApp() + const go = async (to: string) => + (await app.handle(new Request(`http://localhost/auth/return?to=${encodeURIComponent(to)}`))).headers.get('location') + expect(await go('https://tabularis.dev/plugins/bounties?claim=firebird')).toBe( + 'https://tabularis.dev/plugins/bounties?claim=firebird', + ) + expect(await go('/requests')).toBe(`${webBaseUrl()}/requests`) + expect(await go('https://evil.example.com/')).toBe(`${webBaseUrl()}/`) +}) diff --git a/apps/api/tests/routes/mcp-requests.test.ts b/apps/api/tests/routes/mcp-requests.test.ts new file mode 100644 index 00000000..51b1d1f7 --- /dev/null +++ b/apps/api/tests/routes/mcp-requests.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, expect, spyOn, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { ulid } from 'ulid' +import { db } from '../../src/db' +import { pluginRequests } from '../../src/db/schema' +import { clearDb, makeUser, makeAdmin, buildApp, type TestUser } from '../helpers' +import { registerClient, authorize, challenge, resourceUrl, exchangeCode } from '../../src/lib/oauth/server' + +beforeEach(clearDb) + +async function connect(scopes: string, user: TestUser) { + const app = await buildApp() + const clientInfo = await registerClient({ client_name: 'MCP requests test', redirect_uris: ['http://localhost/cb'] }) + const verifier = 'b'.repeat(64) + const p = { + client_id: clientInfo.client_id, + redirect_uri: 'http://localhost/cb', + resource: resourceUrl(), + scope: scopes, + response_type: 'code', + code_challenge: challenge(verifier), + code_challenge_method: 'S256', + } + const code = await authorize(p, { + sub: user.id, + username: user.username, + identityId: user.identityId, + providerInstanceId: 'github', + }) + const token = await exchangeCode({ ...p, code, code_verifier: verifier }) + const client = new Client({ name: 'integration', version: '1.0.0' }) + await client.connect( + new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: async (input, init) => app.handle(new Request(input, init)), + requestInit: { headers: { authorization: `Bearer ${token.access_token}` } }, + }), + ) + return client +} + +async function seed(requester: TestUser, overrides: Partial = {}) { + const row = { + id: ulid(), + slug: 'firebird', + name: 'Firebird', + description: 'Firebird driver', + kind: 'driver', + requesterId: requester.id, + ...overrides, + } + await db.insert(pluginRequests).values(row) + return row +} + +type Data = { data: T; status?: number } + +test('request tools are listed under their scopes', async () => { + const user = await makeUser() + const client = await connect('catalog:read requests:write', user) + try { + const names = (await client.listTools()).tools.map((t) => t.name) + for (const name of ['list_requests', 'get_request', 'toggle_upvote', 'toggle_request_claim', 'scaffold_request']) { + expect(names).toContain(name) + } + expect(names).not.toContain('admin_update_request') + } finally { + await client.close() + } + + const admin = await makeAdmin() + const adminClient = await connect('admin:requests:write', admin) + try { + const names = (await adminClient.listTools()).tools.map((t) => t.name) + expect(names).toContain('admin_update_request') + expect(names).toContain('admin_delete_request') + } finally { + await adminClient.close() + } +}) + +test('a user votes and claims through MCP and sees it on get_request', async () => { + const user = await makeUser({ username: 'builder' }) + const row = await seed(user, { status: 'approved' }) + const client = await connect('catalog:read requests:write', user) + try { + const vote = await client.callTool({ name: 'toggle_upvote', arguments: { params: { id: row.id } } }) + expect(vote.isError).toBe(false) + const claim = await client.callTool({ name: 'toggle_request_claim', arguments: { params: { id: row.id } } }) + expect(claim.isError).toBe(false) + expect((claim.structuredContent as Data<{ claimed: boolean }>).data.claimed).toBe(true) + + const view = await client.callTool({ name: 'get_request', arguments: { params: { id: 'firebird' } } }) + expect(view.isError).toBe(false) + expect((view.structuredContent as Data>).data).toMatchObject({ + status: 'approved', + upvotes: 1, + claims: 1, + votedByMe: true, + claimedByMe: true, + }) + + // The nested body reaches the route as the user: without the workflow scope + // it answers with the re-authorization URL instead of creating anything. + const github = spyOn(global, 'fetch').mockImplementation( + (async () => + new Response(JSON.stringify({ login: 'builder' }), { + status: 200, + headers: { 'x-oauth-scopes': 'public_repo' }, + })) as unknown as typeof fetch, + ) + const scaffold = await client.callTool({ + name: 'scaffold_request', + arguments: { params: { id: row.id }, body: { driver: { dbType: 'file', withUi: true } } }, + }) + github.mockRestore() + expect(scaffold.isError).toBe(true) + expect(scaffold.structuredContent).toMatchObject({ status: 403, data: { missingScope: 'workflow' } }) + } finally { + await client.close() + } +}) + +test('an admin reviews pending requests through MCP', async () => { + const user = await makeUser() + const admin = await makeAdmin() + const row = await seed(user) + const client = await connect('catalog:read admin:requests:write', admin) + try { + const pending = await client.callTool({ name: 'list_requests', arguments: { query: { status: 'pending' } } }) + expect((pending.structuredContent as Data<{ total: number }>).data.total).toBe(1) + + const approved = await client.callTool({ + name: 'admin_update_request', + arguments: { + params: { id: row.id }, + body: { status: 'approved', bounty: { boardStatus: 'most-wanted', difficulty: 'High' } }, + }, + }) + expect(approved.isError).toBe(false) + expect((approved.structuredContent as Data<{ status: string; bounty: unknown }>).data).toMatchObject({ + status: 'approved', + bounty: { boardStatus: 'most-wanted', difficulty: 'High' }, + }) + const audit = await db.query.auditLog.findFirst({ where: { action: 'mcp.admin_update_request' } }) + expect(audit?.actorId).toBe(admin.id) + } finally { + await client.close() + } +}) diff --git a/apps/api/tests/routes/request-lifecycle.test.ts b/apps/api/tests/routes/request-lifecycle.test.ts new file mode 100644 index 00000000..6601cd24 --- /dev/null +++ b/apps/api/tests/routes/request-lifecycle.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { ulid } from 'ulid' +import { + clearDb, + makeUser, + makeAdmin, + makePlugin, + buildApp, + userHeaders, + adminHeaders, + type TestUser, +} from '../helpers' +import { db } from '../../src/db' +import { pluginRequests, webhooks } from '../../src/db/schema' + +type View = { + id: string + slug: string + status: string + bounty: Record | null + claims: number + claimers: Array<{ userId: string; username: string | null; avatarUrl: string | null }> + claimedByMe: boolean + votedByMe: boolean + fulfilledPlugin: { id: string; name: string } | null +} + +async function seedRequest(requester: TestUser, overrides: Partial = {}) { + const row = { + id: ulid(), + slug: 'firebird', + name: 'Firebird', + description: 'Firebird driver', + requesterId: requester.id, + ...overrides, + } + await db.insert(pluginRequests).values(row) + return row +} + +async function call(path: string, init: RequestInit = {}) { + const app = await buildApp() + return app.handle(new Request(`http://localhost${path}`, init)) +} + +function json(body: unknown, headers: Record = {}): RequestInit { + return { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) } +} + +describe('request visibility', () => { + beforeEach(clearDb) + + it('hides pending and rejected requests from anonymous viewers', async () => { + const user = await makeUser() + await seedRequest(user, { slug: 'a', status: 'pending' }) + await seedRequest(user, { slug: 'b', status: 'rejected' }) + await seedRequest(user, { slug: 'c', status: 'approved' }) + await seedRequest(user, { slug: 'd', status: 'fulfilled' }) + + const res = await call('/api/requests?status=pending,rejected,approved,fulfilled') + const data = (await res.json()) as { total: number; requests: View[] } + expect(data.requests.map((r) => r.slug).sort()).toEqual(['c', 'd']) + expect(data.total).toBe(2) + }) + + it('shows pending requests to their requester and to admins only', async () => { + const owner = await makeUser({ username: 'owner' }) + const other = await makeUser({ username: 'other' }) + const admin = await makeAdmin() + await seedRequest(owner, { slug: 'mine', status: 'pending' }) + + const ownList = (await (await call('/api/requests?status=pending', { headers: userHeaders(owner) })).json()) as { + total: number + } + expect(ownList.total).toBe(1) + const otherList = (await (await call('/api/requests?status=pending', { headers: userHeaders(other) })).json()) as { + total: number + } + expect(otherList.total).toBe(0) + const adminList = (await (await call('/api/requests?status=pending', { headers: adminHeaders(admin) })).json()) as { + total: number + } + expect(adminList.total).toBe(1) + + expect((await call('/api/requests/mine')).status).toBe(404) + expect((await call('/api/requests/mine', { headers: userHeaders(other) })).status).toBe(404) + expect((await call('/api/requests/mine', { headers: userHeaders(owner) })).status).toBe(200) + expect((await call('/api/requests/mine', { headers: adminHeaders(admin) })).status).toBe(200) + }) + + it('reads a single request by id or slug', async () => { + const user = await makeUser() + const row = await seedRequest(user, { status: 'approved' }) + const bySlug = (await (await call('/api/requests/firebird')).json()) as View + const byId = (await (await call(`/api/requests/${row.id}`)).json()) as View + expect(bySlug.id).toBe(row.id) + expect(byId.slug).toBe('firebird') + }) +}) + +describe('PATCH /api/admin/requests/:id', () => { + beforeEach(clearDb) + + it('approves a request with bounty metadata and emits request.approved', async () => { + const user = await makeUser() + const admin = await makeAdmin() + const row = await seedRequest(user) + await db.insert(webhooks).values({ + id: ulid(), + name: 'all', + events: JSON.stringify(['request.approved']), + url: 'https://hooks.example.com/x', + createdAt: Date.now(), + updatedAt: Date.now(), + }) + + const bounty = { + boardStatus: 'most-wanted', + tagline: 'Legacy SQL, modern client.', + difficulty: 'High', + accent: '#ff5a3d', + capabilities: [{ label: 'connect', state: 'needed' }], + } + const res = await call(`/api/admin/requests/${row.id}`, json({ status: 'approved', bounty }, adminHeaders(admin))) + expect(res.status).toBe(200) + const view = (await res.json()) as View + expect(view.status).toBe('approved') + expect(view.bounty).toEqual(bounty) + + const stored = await db.query.pluginRequests.findFirst({ where: { id: row.id } }) + expect(stored?.reviewedBy).toBe(admin.id) + const deliveries = await db.query.webhookDeliveries.findMany() + expect(deliveries.map((d) => d.event)).toEqual(['request.approved']) + }) + + it('rejects invalid bounty metadata', async () => { + const user = await makeUser() + const admin = await makeAdmin() + const row = await seedRequest(user) + const res = await call( + `/api/admin/requests/${row.id}`, + json({ bounty: { accent: 'red', difficulty: 'Impossible' } }, adminHeaders(admin)), + ) + expect(res.status).toBe(422) + }) + + it('stores the rejection reason', async () => { + const user = await makeUser() + const admin = await makeAdmin() + const row = await seedRequest(user) + const res = await call( + `/api/admin/requests/${row.id}`, + json({ status: 'rejected', rejectionReason: 'Duplicate of mongodb' }, adminHeaders(admin)), + ) + expect(((await res.json()) as { rejectionReason: string }).rejectionReason).toBe('Duplicate of mongodb') + }) + + it('refuses to mark fulfilled without a matching plugin', async () => { + const user = await makeUser() + const admin = await makeAdmin() + const row = await seedRequest(user, { status: 'approved' }) + const res = await call(`/api/admin/requests/${row.id}`, json({ status: 'fulfilled' }, adminHeaders(admin))) + expect(res.status).toBe(400) + }) + + it('is admin only', async () => { + const user = await makeUser() + const row = await seedRequest(user) + const res = await call(`/api/admin/requests/${row.id}`, json({ status: 'approved' }, userHeaders(user))) + expect(res.status).toBe(403) + }) +}) + +describe('claims and votes on the lifecycle', () => { + beforeEach(clearDb) + + it('only takes new claims and votes on approved requests', async () => { + const user = await makeUser() + const row = await seedRequest(user, { status: 'pending' }) + const post = { method: 'POST', headers: userHeaders(user) } + expect((await call(`/api/requests/${row.id}/claim`, post)).status).toBe(409) + expect((await call(`/api/requests/${row.id}/upvote`, post)).status).toBe(409) + }) + + it('lists claimers with GitHub avatars and fills votedByMe', async () => { + const user = await makeUser({ username: 'builder', externalId: '4242' }) + const row = await seedRequest(user, { status: 'approved' }) + const post = { method: 'POST', headers: userHeaders(user) } + + const claim = (await (await call(`/api/requests/${row.id}/claim`, post)).json()) as { + claimed: boolean + claimers: View['claimers'] + } + expect(claim.claimed).toBe(true) + expect(claim.claimers).toHaveLength(1) + expect(claim.claimers[0]).toMatchObject({ + username: 'builder', + avatarUrl: 'https://avatars.githubusercontent.com/u/4242?v=4', + }) + await call(`/api/requests/${row.id}/upvote`, post) + + const view = (await (await call('/api/requests/firebird', { headers: userHeaders(user) })).json()) as View + expect(view).toMatchObject({ claims: 1, claimedByMe: true, votedByMe: true }) + const anon = (await (await call('/api/requests/firebird')).json()) as View + expect(anon).toMatchObject({ claims: 1, claimedByMe: false, votedByMe: false }) + }) + + it('still lets a claimer release a claim after the request ships', async () => { + const user = await makeUser() + const row = await seedRequest(user, { status: 'approved' }) + const post = { method: 'POST', headers: userHeaders(user) } + await call(`/api/requests/${row.id}/claim`, post) + await db.update(pluginRequests).set({ status: 'fulfilled' }) + const res = await call(`/api/requests/${row.id}/claim`, post) + expect(res.status).toBe(200) + expect(((await res.json()) as { claimed: boolean }).claimed).toBe(false) + }) +}) + +describe('fulfillment', () => { + beforeEach(clearDb) + + it('fulfills the request when an admin approves the plugin with the same slug', async () => { + const user = await makeUser() + const admin = await makeAdmin() + const row = await seedRequest(user, { status: 'approved' }) + await makePlugin(user.id, { id: 'firebird', name: 'Firebird', status: 'pending' }) + + const res = await call('/api/admin/plugins/firebird', json({ status: 'approved' }, adminHeaders(admin))) + expect(res.status).toBe(200) + + const view = (await (await call(`/api/requests/${row.id}`)).json()) as View + expect(view.status).toBe('fulfilled') + expect(view.fulfilledPlugin).toEqual({ id: 'firebird', name: 'Firebird' }) + }) + + it('leaves rejected requests alone', async () => { + const user = await makeUser() + const admin = await makeAdmin() + const row = await seedRequest(user, { status: 'rejected' }) + await makePlugin(user.id, { id: 'firebird', status: 'pending' }) + await call('/api/admin/plugins/firebird', json({ status: 'approved' }, adminHeaders(admin))) + const stored = await db.query.pluginRequests.findFirst({ where: { id: row.id } }) + expect(stored?.status).toBe('rejected') + }) +}) + +describe('fulfillment on first manifest', () => { + beforeEach(clearDb) + + it('ships the request when an approved plugin gets its first manifest', async () => { + const { applyManifestToPlugin } = await import('../../src/lib/manifest-apply') + const user = await makeUser() + const row = await seedRequest(user, { status: 'approved' }) + await makePlugin(user.id, { id: 'firebird', status: 'approved', manifestVersion: null }) + + await applyManifestToPlugin('firebird', { manifestVersion: null, updatedAt: Date.now() } as never) + expect((await db.query.pluginRequests.findFirst({ where: { id: row.id } }))?.status).toBe('approved') + + await applyManifestToPlugin('firebird', { manifestVersion: '0.1.0', updatedAt: Date.now() } as never) + expect((await db.query.pluginRequests.findFirst({ where: { id: row.id } }))?.status).toBe('fulfilled') + }) +}) diff --git a/apps/api/tests/routes/request-scaffold.test.ts b/apps/api/tests/routes/request-scaffold.test.ts new file mode 100644 index 00000000..551f2f81 --- /dev/null +++ b/apps/api/tests/routes/request-scaffold.test.ts @@ -0,0 +1,136 @@ +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, overrides: Partial = {}) { + const row = { + id: ulid(), + slug: 'firebird', + name: 'Firebird', + description: 'Firebird driver', + kind: 'driver', + status: 'approved' as const, + requesterId: user.id, + ...overrides, + } + await db.insert(pluginRequests).values(row) + return row +} + +async function scaffold(user: TestUser, id: string, body: Record = {}) { + const app = await buildApp() + return app.handle( + new Request(`http://localhost/api/requests/${id}/scaffold`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...userHeaders(user) }, + body: JSON.stringify(body), + }), + ) +} + +type Call = { method: string; url: string; body: Record | null } + +function mockGithub(scopes: string | null) { + const calls: Call[] = [] + const spy = spyOn(global, 'fetch').mockImplementation((async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? 'GET' + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : null + calls.push({ method, url, body }) + const ok = (data: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(data), { status, headers }) + if (url.endsWith('/user') && method === 'GET') { + return ok({ login: 'builder' }, 200, scopes === null ? {} : { 'x-oauth-scopes': scopes }) + } + if (url.endsWith('/user/repos')) { + return ok( + { + name: body?.name, + full_name: `builder/${body?.name}`, + html_url: `https://github.com/builder/${body?.name}`, + default_branch: 'main', + owner: { login: 'builder' }, + }, + 201, + ) + } + if (url.endsWith('/git/blobs')) return ok({ sha: `blob${calls.length}` }, 201) + if (url.endsWith('/git/trees')) return ok({ sha: 'tree1' }, 201) + if (url.endsWith('/git/commits')) return ok({ sha: 'commit1' }, 201) + if (url.includes('/git/refs/heads/')) return ok({ ref: 'refs/heads/main' }) + return new Response('not found', { status: 404 }) + }) as unknown as typeof fetch) + return { calls, restore: () => spy.mockRestore() } +} + +describe('POST /api/requests/:id/scaffold', () => { + beforeEach(clearDb) + + it('requires a claim on an approved request', async () => { + const user = await makeUser() + const row = await seed(user) + const res = await scaffold(user, row.id) + expect(res.status).toBe(409) + }) + + it('asks for the workflow scope with a re-authorization URL', async () => { + const user = await makeUser() + const row = await seed(user) + await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id }) + const gh = mockGithub('read:user, public_repo, admin:repo_hook') + const res = await scaffold(user, row.id, { returnTo: 'http://localhost:3000/requests' }) + gh.restore() + + expect(res.status).toBe(403) + const data = (await res.json()) as { missingScope: string; reauthUrl: string } + expect(data.missingScope).toBe('workflow') + const url = new URL(data.reauthUrl) + expect(url.pathname).toBe('/auth/github') + expect(url.searchParams.get('scope')).toBe('workflow') + expect(url.searchParams.get('return_to')).toBe('http://localhost:3000/requests') + expect(gh.calls.some((c) => c.url.endsWith('/user/repos'))).toBe(false) + }) + + it('needs a minimum runtime version for themes', async () => { + const user = await makeUser() + const row = await seed(user, { slug: 'dracula', kind: 'theme' }) + await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id }) + const res = await scaffold(user, row.id) + expect(res.status).toBe(400) + }) + + it('creates the repository with the generated driver 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 gh = mockGithub('public_repo, workflow') + const res = await scaffold(user, row.id, { driver: { dbType: 'file' } }) + gh.restore() + + expect(res.status).toBe(200) + const data = (await res.json()) as { repoUrl: string; files: number; submitUrl: string; claimers: unknown[] } + expect(data.repoUrl).toBe('https://github.com/builder/firebird') + expect(data.submitUrl).toContain('/submit?repo=') + + const repoCall = gh.calls.find((c) => c.url.endsWith('/user/repos'))! + expect(repoCall.body).toMatchObject({ name: 'firebird', private: false, auto_init: true }) + const tree = gh.calls.find((c) => c.url.endsWith('/git/trees'))!.body!.tree as Array<{ path: string }> + const paths = tree.map((e) => e.path) + expect(paths).toContain('.tabularium') + expect(paths).toContain('.github/workflows/release.yml') + expect(paths).toContain('.gitignore') + expect(paths).toHaveLength(data.files) + const blobs = gh.calls.filter((c) => c.url.endsWith('/git/blobs')) + const manifest = blobs[paths.indexOf('.tabularium')].body!.content as string + expect(JSON.parse(Buffer.from(manifest, 'base64').toString('utf8'))).toMatchObject({ name: 'firebird' }) + const commit = gh.calls.find((c) => c.url.endsWith('/git/commits'))! + expect(commit.body).toMatchObject({ tree: 'tree1', parents: [] }) + const ref = gh.calls.find((c) => c.url.includes('/git/refs/heads/main'))! + expect(ref.body).toMatchObject({ sha: 'commit1', force: true }) + + const claim = await db.query.pluginRequestClaims.findFirst({ where: { requestId: row.id } }) + expect(claim?.repoUrl).toBe('https://github.com/builder/firebird') + }) +}) diff --git a/apps/api/tests/routes/requests.test.ts b/apps/api/tests/routes/requests.test.ts index 245682ce..3b8645c2 100644 --- a/apps/api/tests/routes/requests.test.ts +++ b/apps/api/tests/routes/requests.test.ts @@ -20,8 +20,24 @@ describe('GET /api/requests', () => { it('sorts by upvotes by default', async () => { const user = await makeUser() await db.insert(pluginRequests).values([ - { id: ulid(), slug: 'mongo', name: 'MongoDB', description: 'Mongo', requesterId: user.id, upvotes: 5 }, - { id: ulid(), slug: 'mysql', name: 'MySQL', description: 'MySQL', requesterId: user.id, upvotes: 10 }, + { + id: ulid(), + slug: 'mongo', + name: 'MongoDB', + description: 'Mongo', + requesterId: user.id, + upvotes: 5, + status: 'approved', + }, + { + id: ulid(), + slug: 'mysql', + name: 'MySQL', + description: 'MySQL', + requesterId: user.id, + upvotes: 10, + status: 'approved', + }, ]) const app = await buildApp() @@ -131,6 +147,11 @@ describe('POST /api/requests — derived slug and kind', () => { const missing = await post({ name: 'Dracula', description: 'Theme' }) expect(missing.status).toBe(400) + // New requests wait for review before they are listed publicly. + const hidden = await app.handle(new Request('http://localhost/api/requests?kind=driver')) + expect(((await hidden.json()) as { total: number }).total).toBe(0) + await db.update(pluginRequests).set({ status: 'approved' }) + const res = await app.handle(new Request('http://localhost/api/requests?kind=driver')) const data = (await res.json()) as { total: number; requests: Array<{ slug: string; kind: string | null }> } expect(data.total).toBe(1) @@ -157,6 +178,7 @@ describe('POST /api/requests/:id/claim', () => { description: 'Wide-column store', requesterId: user.id, upvotes: 0, + status: 'approved', }) const app = await buildApp() @@ -224,6 +246,7 @@ describe('POST /api/requests/:id/claim', () => { description: 'KV', requesterId: user.id, upvotes: 0, + status: 'approved', }) await db.insert(pluginRequestClaims).values([ { requestId, userId: user.id }, @@ -267,6 +290,7 @@ describe('DELETE /api/admin/requests/:id', () => { description: 'Streaming', requesterId: admin.id, upvotes: 0, + status: 'approved', }) await db.insert(pluginRequestClaims).values({ requestId, userId: claimer.id }) @@ -341,6 +365,7 @@ describe('POST /api/requests/:id/upvote', () => { description: 'Search engine', requesterId: user.id, upvotes: 0, + status: 'approved', }) const app = await buildApp() diff --git a/apps/api/tests/routes/submit.test.ts b/apps/api/tests/routes/submit.test.ts index 6680e09f..52bc87fb 100644 --- a/apps/api/tests/routes/submit.test.ts +++ b/apps/api/tests/routes/submit.test.ts @@ -62,7 +62,7 @@ describe('POST /api/submit/oauth', () => { expect(saved).toBeDefined() }) - it('removes the matching plugin request on success', async () => { + it('keeps the matching request in progress until the plugin is in the catalog', async () => { const user = await makeUser({ username: 'alice' }) const token = await signJwt({ sub: user.id, @@ -78,6 +78,7 @@ describe('POST /api/submit/oauth', () => { description: 'desc', requesterId: user.id, upvotes: 0, + status: 'approved', }) await db.insert(pluginRequestClaims).values({ requestId, userId: user.id }) @@ -105,10 +106,12 @@ describe('POST /api/submit/oauth', () => { fetchSpy.mockRestore() expect(res.status).toBe(200) - const gone = await db.query.pluginRequests.findFirst({ where: { id: requestId } }) - expect(gone).toBeUndefined() - const claimGone = await db.query.pluginRequestClaims.findFirst({ where: { requestId } }) - expect(claimGone).toBeUndefined() + // No release yet, so no manifest: the plugin is not listed and the + // request stays open (and claimed) instead of being deleted. + const pending = await db.query.pluginRequests.findFirst({ where: { id: requestId } }) + expect(pending).toMatchObject({ status: 'approved', fulfilledPluginId: null }) + const claim = await db.query.pluginRequestClaims.findFirst({ where: { requestId } }) + expect(claim).toBeDefined() }) it('returns 403 when user does not own the repo', async () => { diff --git a/apps/docs/src/routes/admin/features/+page.md b/apps/docs/src/routes/admin/features/+page.md index 9b0290a4..4fa97cff 100644 --- a/apps/docs/src/routes/admin/features/+page.md +++ b/apps/docs/src/routes/admin/features/+page.md @@ -11,7 +11,7 @@ Feature flags let an operator temporarily disable parts of the registry without | Flag | Off behaviour | |------|---------------| | `features.submissions_enabled` | `/submit` shows a "submissions disabled" notice. `POST /api/submit/oauth` returns 403. Existing plugins keep working. | -| `features.requests_enabled` | `/requests` hides the create form. `POST /api/requests` returns 403. Upvotes on existing requests still work. | +| `features.requests_enabled` | `/requests` hides the create form. `POST /api/requests` returns 403. Upvotes and claims on approved requests still work. | ## Use cases @@ -20,3 +20,20 @@ Feature flags let an operator temporarily disable parts of the registry without - **Soft launch** — keep submissions off until the registry has enough featured plugins. Toggle them in `/admin/features`. The change is live in the next API call — no restart. + +## Request lifecycle + +Plugin requests are reviewed before they become public bounties: + +| Status | Who sees it | How it gets there | +|--------|-------------|-------------------| +| `pending` | Admins and the requester | Every new request starts here. | +| `approved` | Everyone | An admin approves it in `/admin/requests` and fills in the bounty details (tagline, scope, difficulty, next step, board label). Users can upvote and claim it; with at least one claim it shows as in progress. | +| `rejected` | Admins and the requester | An admin rejects it, optionally with a reason. | +| `fulfilled` | Everyone | A plugin whose slug matches the request is approved and has a manifest ingested from a release. The request stays, linked to the plugin. | + +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. + +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` against the target `DATABASE_URL`. Use `--dry-run` to preview. diff --git a/apps/docs/src/routes/mcp/+page.md b/apps/docs/src/routes/mcp/+page.md index 919fa865..a020f71f 100644 --- a/apps/docs/src/routes/mcp/+page.md +++ b/apps/docs/src/routes/mcp/+page.md @@ -29,13 +29,13 @@ Effective access is the intersection of granted scopes, the user's current role | `catalog:read` | Plugin search/detail/stats/integrity, docs, pages, kinds, public instance metadata, community requests, manifest validation | | `account:read` | Own profile, linked repositories and transfers | | `plugins:write` | Submit/preview, publish, delete own plugins, transfers, manifest refresh, rehash and yank | -| `requests:write` | Create requests, vote and claim | +| `requests:write` | Create requests, vote, claim and create a scaffolded repository for a claimed request | | `admin::read` | Read an administrative area as a current admin | | `admin::write` | Change an administrative area as a current admin | Admin areas: `plugins`, `users`, `manifest`, `docs`, `pages`, `settings`, `provider-instances`, `features`, `kinds`, `branding`, `home-copy`, `i18n`, `instance`, `infra`, `audit`, `diagnostics`, `requests`. Unknown scopes are rejected. A non-admin cannot grant admin scopes. Role demotion removes access on the next request even if the token has not expired. -Examples: a catalog assistant requests `catalog:read`; a publishing assistant requests `catalog:read account:read plugins:write`; a moderation assistant requests `catalog:read admin:plugins:read admin:plugins:write`. The default when scope is omitted is only `catalog:read`. +Examples: a catalog assistant requests `catalog:read`; a publishing assistant requests `catalog:read account:read plugins:write`; a moderation assistant requests `catalog:read admin:plugins:read admin:plugins:write`. A request-review assistant requests `catalog:read admin:requests:write` to list pending requests and approve, reject or edit their bounty details. The default when scope is omitted is only `catalog:read`. Existing `tbm_` admin API tokens now enforce these exact area scopes too. Legacy `scopes: null` retains full-admin access; unknown legacy scope strings grant no matching action. Scoped credentials cannot create tokens or alter recovery authentication. Browser session JWTs and publisher/admin API tokens are not accepted as MCP access tokens. diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index 523c7043..14c470a8 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -1161,5 +1161,61 @@ "app_home_title": "Plugins brauchen zuerst die App.", "app_home_body": "Jedes Plugin in dieser Registry lässt sich mit einem Klick aus {app} installieren. Lade die App herunter und erweitere sie mit den Treibern und Themes, die du brauchst.", "app_plugin_note": "Dieses Plugin läuft in {app}.", - "app_plugin_note_missing": "Noch nicht installiert?" + "app_plugin_note_missing": "Noch nicht installiert?", + "admin_nav_requests": "Anfragen", + "admin_requests_title": "Plugin-Anfragen", + "admin_requests_subtitle": "Prüfe Community-Anfragen und ergänze die Bounty-Details, die auf dem Board erscheinen.", + "admin_requests_tab_pending": "Ausstehend", + "admin_requests_tab_approved": "Freigegeben", + "admin_requests_empty": "Nichts vorhanden.", + "admin_requests_approve": "Freigeben", + "admin_requests_reject": "Ablehnen", + "admin_requests_reopen": "Zurück auf ausstehend", + "admin_requests_reject_reason": "Ablehnungsgrund", + "admin_requests_bounty": "Bounty-Details", + "admin_requests_board_status": "Board-Label", + "admin_requests_board_open": "Offen", + "admin_requests_board_most_wanted": "Meistgewünscht", + "admin_requests_board_scoped": "Umrissen", + "admin_requests_board_coming_soon": "Demnächst", + "admin_requests_tagline": "Tagline", + "admin_requests_stage": "Phase", + "admin_requests_next_step": "Nächster Schritt", + "admin_requests_difficulty": "Schwierigkeit", + "admin_requests_accent": "Akzentfarbe", + "admin_requests_tags": "Tags (kommagetrennt)", + "admin_requests_capabilities": "Umfang, eine Zeile pro Eintrag: Label = ready | needed | stretch", + "admin_requests_signal": "Signal", + "admin_requests_sponsor_url": "Sponsor-URL", + "admin_requests_save": "Speichern", + "admin_requests_saved": "Gespeichert", + "requests_created_pending": "Anfrage gesendet. Sie wird öffentlich, sobald ein Admin sie freigibt.", + "requests_status_pending": "In Prüfung", + "requests_status_rejected": "Abgelehnt", + "requests_status_in_progress": "In Arbeit", + "requests_status_shipped": "Veröffentlicht", + "requests_view_plugin": "Plugin ansehen", + "requests_repo": "Repo", + "requests_start_building": "Loslegen", + "requests_scaffold_title": "{name} entwickeln", + "requests_scaffold_intro": "Erstelle ein GitHub-Repository mit dem von @tabularis/create-plugin generierten Plugin. Es trägt den Namen dieser Anfrage, die sich schließt, sobald das Plugin gelistet ist.", + "requests_scaffold_repo_name": "Repository-Name", + "requests_scaffold_owner": "Organisation", + "requests_scaffold_owner_hint": "Dein Konto", + "requests_scaffold_private": "Privates Repository", + "requests_scaffold_db_type": "Verbindungstyp", + "requests_scaffold_db_network": "Netzwerk (Host, Port, Benutzer)", + "requests_scaffold_db_file": "Datei", + "requests_scaffold_db_folder": "Ordner", + "requests_scaffold_db_api": "API (ohne Verbindungsformular)", + "requests_scaffold_with_ui": "UI-Erweiterung einschließen", + "requests_scaffold_min_runtime": "Minimale Tabularis-Version", + "requests_scaffold_local": "Lieber lokal starten?", + "requests_scaffold_create": "Repository erstellen", + "requests_scaffold_creating": "Wird erstellt…", + "requests_scaffold_grant": "Zugriff gewähren", + "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" } diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 1cef4b23..0711c456 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -1186,5 +1186,61 @@ "admin_plugins_action_featured": "Featured", "admin_plugins_action_verified": "Verified", "admin_plugins_action_replay": "Sync release", - "admin_plugins_actions": "Actions" + "admin_plugins_actions": "Actions", + "admin_nav_requests": "Requests", + "admin_requests_title": "Plugin requests", + "admin_requests_subtitle": "Review community requests and fill in the bounty details shown on the board.", + "admin_requests_tab_pending": "Pending", + "admin_requests_tab_approved": "Approved", + "admin_requests_empty": "Nothing here.", + "admin_requests_approve": "Approve", + "admin_requests_reject": "Reject", + "admin_requests_reopen": "Move back to pending", + "admin_requests_reject_reason": "Rejection reason", + "admin_requests_bounty": "Bounty details", + "admin_requests_board_status": "Board label", + "admin_requests_board_open": "Open", + "admin_requests_board_most_wanted": "Most wanted", + "admin_requests_board_scoped": "Scoped", + "admin_requests_board_coming_soon": "Coming soon", + "admin_requests_tagline": "Tagline", + "admin_requests_stage": "Stage", + "admin_requests_next_step": "Next step", + "admin_requests_difficulty": "Difficulty", + "admin_requests_accent": "Accent color", + "admin_requests_tags": "Tags (comma-separated)", + "admin_requests_capabilities": "Scope, one per line: label = ready | needed | stretch", + "admin_requests_signal": "Signal", + "admin_requests_sponsor_url": "Sponsor URL", + "admin_requests_save": "Save", + "admin_requests_saved": "Saved", + "requests_created_pending": "Request sent. It shows up publicly once an admin approves it.", + "requests_status_pending": "Pending review", + "requests_status_rejected": "Rejected", + "requests_status_in_progress": "In progress", + "requests_status_shipped": "Shipped", + "requests_view_plugin": "View plugin", + "requests_repo": "repo", + "requests_start_building": "Start building", + "requests_scaffold_title": "Start building {name}", + "requests_scaffold_intro": "Create a GitHub repository with the plugin scaffolded by @tabularis/create-plugin. It is named after this request, so the request ships as soon as the plugin is listed.", + "requests_scaffold_repo_name": "Repository name", + "requests_scaffold_owner": "Organization", + "requests_scaffold_owner_hint": "Your account", + "requests_scaffold_private": "Private repository", + "requests_scaffold_db_type": "Connection type", + "requests_scaffold_db_network": "Network (host, port, user)", + "requests_scaffold_db_file": "File", + "requests_scaffold_db_folder": "Folder", + "requests_scaffold_db_api": "API (no connection form)", + "requests_scaffold_with_ui": "Include a UI extension", + "requests_scaffold_min_runtime": "Minimum Tabularis version", + "requests_scaffold_local": "Prefer to start locally?", + "requests_scaffold_create": "Create repository", + "requests_scaffold_creating": "Creating…", + "requests_scaffold_grant": "Grant access", + "requests_scaffold_done": "{repo} is ready.", + "requests_scaffold_next": "Build the plugin, publish a release, then submit the repository to the registry.", + "requests_scaffold_open_repo": "Open repository", + "requests_scaffold_submit": "Submit to the registry" } diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 42332d2e..1de328f0 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -1161,5 +1161,61 @@ "app_home_title": "Los plugins necesitan la app.", "app_home_body": "Cada plugin de este registro se instala con un clic desde {app}. Descarga la app y amplíala con los drivers y temas que necesites.", "app_plugin_note": "Este plugin funciona dentro de {app}.", - "app_plugin_note_missing": "¿Aún no la tienes?" + "app_plugin_note_missing": "¿Aún no la tienes?", + "admin_nav_requests": "Solicitudes", + "admin_requests_title": "Solicitudes de plugins", + "admin_requests_subtitle": "Revisa las solicitudes de la comunidad y completa los detalles de la recompensa que se muestran en el tablero.", + "admin_requests_tab_pending": "Pendientes", + "admin_requests_tab_approved": "Aprobadas", + "admin_requests_empty": "No hay nada aquí.", + "admin_requests_approve": "Aprobar", + "admin_requests_reject": "Rechazar", + "admin_requests_reopen": "Volver a pendiente", + "admin_requests_reject_reason": "Motivo del rechazo", + "admin_requests_bounty": "Detalles de la recompensa", + "admin_requests_board_status": "Etiqueta en el tablero", + "admin_requests_board_open": "Abierta", + "admin_requests_board_most_wanted": "Más pedida", + "admin_requests_board_scoped": "Definida", + "admin_requests_board_coming_soon": "Próximamente", + "admin_requests_tagline": "Lema", + "admin_requests_stage": "Fase", + "admin_requests_next_step": "Siguiente paso", + "admin_requests_difficulty": "Dificultad", + "admin_requests_accent": "Color de acento", + "admin_requests_tags": "Etiquetas (separadas por comas)", + "admin_requests_capabilities": "Alcance, uno por línea: etiqueta = ready | needed | stretch", + "admin_requests_signal": "Señal", + "admin_requests_sponsor_url": "URL de patrocinio", + "admin_requests_save": "Guardar", + "admin_requests_saved": "Guardado", + "requests_created_pending": "Solicitud enviada. Será pública cuando un administrador la apruebe.", + "requests_status_pending": "En revisión", + "requests_status_rejected": "Rechazada", + "requests_status_in_progress": "En curso", + "requests_status_shipped": "Publicada", + "requests_view_plugin": "Ver plugin", + "requests_repo": "repo", + "requests_start_building": "Empezar a desarrollar", + "requests_scaffold_title": "Desarrollar {name}", + "requests_scaffold_intro": "Crea un repositorio de GitHub con el plugin generado por @tabularis/create-plugin. Lleva el nombre de esta solicitud, que se cierra en cuanto el plugin aparece en el catálogo.", + "requests_scaffold_repo_name": "Nombre del repositorio", + "requests_scaffold_owner": "Organización", + "requests_scaffold_owner_hint": "Tu cuenta", + "requests_scaffold_private": "Repositorio privado", + "requests_scaffold_db_type": "Tipo de conexión", + "requests_scaffold_db_network": "Red (host, puerto, usuario)", + "requests_scaffold_db_file": "Archivo", + "requests_scaffold_db_folder": "Carpeta", + "requests_scaffold_db_api": "API (sin formulario de conexión)", + "requests_scaffold_with_ui": "Incluir una extensión de interfaz", + "requests_scaffold_min_runtime": "Versión mínima de Tabularis", + "requests_scaffold_local": "¿Prefieres empezar en local?", + "requests_scaffold_create": "Crear repositorio", + "requests_scaffold_creating": "Creando…", + "requests_scaffold_grant": "Conceder acceso", + "requests_scaffold_done": "{repo} está listo.", + "requests_scaffold_next": "Desarrolla el plugin, publica una release y luego envía el repositorio al registro.", + "requests_scaffold_open_repo": "Abrir repositorio", + "requests_scaffold_submit": "Enviar al registro" } diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index cbde3cc1..393fb837 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -1161,5 +1161,61 @@ "app_home_title": "Les plugins ont besoin de l'application.", "app_home_body": "Chaque plugin de ce registre s'installe en un clic depuis {app}. Téléchargez l'application, puis étendez-la avec les pilotes et thèmes dont vous avez besoin.", "app_plugin_note": "Ce plugin fonctionne dans {app}.", - "app_plugin_note_missing": "Vous ne l'avez pas encore ?" + "app_plugin_note_missing": "Vous ne l'avez pas encore ?", + "admin_nav_requests": "Demandes", + "admin_requests_title": "Demandes de plugins", + "admin_requests_subtitle": "Examinez les demandes de la communauté et complétez les détails de la prime affichés sur le tableau.", + "admin_requests_tab_pending": "En attente", + "admin_requests_tab_approved": "Approuvées", + "admin_requests_empty": "Rien ici.", + "admin_requests_approve": "Approuver", + "admin_requests_reject": "Refuser", + "admin_requests_reopen": "Remettre en attente", + "admin_requests_reject_reason": "Motif du refus", + "admin_requests_bounty": "Détails de la prime", + "admin_requests_board_status": "Étiquette sur le tableau", + "admin_requests_board_open": "Ouverte", + "admin_requests_board_most_wanted": "La plus demandée", + "admin_requests_board_scoped": "Cadrée", + "admin_requests_board_coming_soon": "Bientôt", + "admin_requests_tagline": "Accroche", + "admin_requests_stage": "Étape", + "admin_requests_next_step": "Prochaine étape", + "admin_requests_difficulty": "Difficulté", + "admin_requests_accent": "Couleur d'accent", + "admin_requests_tags": "Tags (séparés par des virgules)", + "admin_requests_capabilities": "Périmètre, un par ligne : libellé = ready | needed | stretch", + "admin_requests_signal": "Signal", + "admin_requests_sponsor_url": "URL de sponsoring", + "admin_requests_save": "Enregistrer", + "admin_requests_saved": "Enregistré", + "requests_created_pending": "Demande envoyée. Elle sera publique dès qu'un administrateur l'aura approuvée.", + "requests_status_pending": "En cours d'examen", + "requests_status_rejected": "Refusée", + "requests_status_in_progress": "En cours", + "requests_status_shipped": "Publiée", + "requests_view_plugin": "Voir le plugin", + "requests_repo": "dépôt", + "requests_start_building": "Commencer le développement", + "requests_scaffold_title": "Développer {name}", + "requests_scaffold_intro": "Créez un dépôt GitHub avec le plugin généré par @tabularis/create-plugin. Il porte le nom de cette demande, qui se clôt dès que le plugin est référencé.", + "requests_scaffold_repo_name": "Nom du dépôt", + "requests_scaffold_owner": "Organisation", + "requests_scaffold_owner_hint": "Votre compte", + "requests_scaffold_private": "Dépôt privé", + "requests_scaffold_db_type": "Type de connexion", + "requests_scaffold_db_network": "Réseau (hôte, port, utilisateur)", + "requests_scaffold_db_file": "Fichier", + "requests_scaffold_db_folder": "Dossier", + "requests_scaffold_db_api": "API (sans formulaire de connexion)", + "requests_scaffold_with_ui": "Inclure une extension d'interface", + "requests_scaffold_min_runtime": "Version minimale de Tabularis", + "requests_scaffold_local": "Vous préférez démarrer en local ?", + "requests_scaffold_create": "Créer le dépôt", + "requests_scaffold_creating": "Création…", + "requests_scaffold_grant": "Accorder l'accès", + "requests_scaffold_done": "{repo} est prêt.", + "requests_scaffold_next": "Développez le plugin, publiez une release, puis soumettez le dépôt au registre.", + "requests_scaffold_open_repo": "Ouvrir le dépôt", + "requests_scaffold_submit": "Soumettre au registre" } diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index 84bd5177..72ddb95a 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -1186,5 +1186,61 @@ "admin_plugins_action_featured": "In evidenza", "admin_plugins_action_verified": "Verificato", "admin_plugins_action_replay": "Sincronizza release", - "admin_plugins_actions": "Azioni" + "admin_plugins_actions": "Azioni", + "admin_nav_requests": "Richieste", + "admin_requests_title": "Richieste di plugin", + "admin_requests_subtitle": "Rivedi le richieste della community e compila i dettagli della bounty mostrati sulla board.", + "admin_requests_tab_pending": "In attesa", + "admin_requests_tab_approved": "Approvate", + "admin_requests_empty": "Nessuna richiesta.", + "admin_requests_approve": "Approva", + "admin_requests_reject": "Rifiuta", + "admin_requests_reopen": "Rimetti in attesa", + "admin_requests_reject_reason": "Motivo del rifiuto", + "admin_requests_bounty": "Dettagli bounty", + "admin_requests_board_status": "Etichetta sulla board", + "admin_requests_board_open": "Aperta", + "admin_requests_board_most_wanted": "Più richiesta", + "admin_requests_board_scoped": "Definita", + "admin_requests_board_coming_soon": "In arrivo", + "admin_requests_tagline": "Tagline", + "admin_requests_stage": "Fase", + "admin_requests_next_step": "Prossimo passo", + "admin_requests_difficulty": "Difficoltà", + "admin_requests_accent": "Colore", + "admin_requests_tags": "Tag (separati da virgola)", + "admin_requests_capabilities": "Scope, uno per riga: etichetta = ready | needed | stretch", + "admin_requests_signal": "Segnale", + "admin_requests_sponsor_url": "URL sponsor", + "admin_requests_save": "Salva", + "admin_requests_saved": "Salvato", + "requests_created_pending": "Richiesta inviata. Sarà pubblica dopo l'approvazione di un admin.", + "requests_status_pending": "In revisione", + "requests_status_rejected": "Rifiutata", + "requests_status_in_progress": "In lavorazione", + "requests_status_shipped": "Rilasciata", + "requests_view_plugin": "Vedi plugin", + "requests_repo": "repo", + "requests_start_building": "Inizia a sviluppare", + "requests_scaffold_title": "Inizia a sviluppare {name}", + "requests_scaffold_intro": "Crea un repository GitHub con il plugin generato da @tabularis/create-plugin. Il plugin prende il nome della richiesta, che si chiude appena il plugin è nel catalogo.", + "requests_scaffold_repo_name": "Nome del repository", + "requests_scaffold_owner": "Organizzazione", + "requests_scaffold_owner_hint": "Il tuo account", + "requests_scaffold_private": "Repository privato", + "requests_scaffold_db_type": "Tipo di connessione", + "requests_scaffold_db_network": "Rete (host, porta, utente)", + "requests_scaffold_db_file": "File", + "requests_scaffold_db_folder": "Cartella", + "requests_scaffold_db_api": "API (senza form di connessione)", + "requests_scaffold_with_ui": "Includi un'estensione UI", + "requests_scaffold_min_runtime": "Versione minima di Tabularis", + "requests_scaffold_local": "Preferisci partire in locale?", + "requests_scaffold_create": "Crea repository", + "requests_scaffold_creating": "Creazione…", + "requests_scaffold_grant": "Concedi l'accesso", + "requests_scaffold_done": "{repo} è pronto.", + "requests_scaffold_next": "Sviluppa il plugin, pubblica una release, poi invia il repository al registry.", + "requests_scaffold_open_repo": "Apri il repository", + "requests_scaffold_submit": "Invia al registry" } diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index af28b9b7..625c08ca 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -1161,5 +1161,61 @@ "app_home_title": "插件需要先安装应用。", "app_home_body": "本仓库中的每个插件都可以在 {app} 中一键安装。先下载应用,再用你需要的驱动和主题扩展它。", "app_plugin_note": "此插件运行在 {app} 中。", - "app_plugin_note_missing": "还没有安装?" + "app_plugin_note_missing": "还没有安装?", + "admin_nav_requests": "请求", + "admin_requests_title": "插件请求", + "admin_requests_subtitle": "审核社区请求,并填写悬赏看板上显示的悬赏详情。", + "admin_requests_tab_pending": "待审核", + "admin_requests_tab_approved": "已批准", + "admin_requests_empty": "暂无内容。", + "admin_requests_approve": "批准", + "admin_requests_reject": "拒绝", + "admin_requests_reopen": "改回待审核", + "admin_requests_reject_reason": "拒绝原因", + "admin_requests_bounty": "悬赏详情", + "admin_requests_board_status": "看板标签", + "admin_requests_board_open": "开放", + "admin_requests_board_most_wanted": "最受期待", + "admin_requests_board_scoped": "已明确范围", + "admin_requests_board_coming_soon": "即将推出", + "admin_requests_tagline": "标语", + "admin_requests_stage": "阶段", + "admin_requests_next_step": "下一步", + "admin_requests_difficulty": "难度", + "admin_requests_accent": "强调色", + "admin_requests_tags": "标签(以逗号分隔)", + "admin_requests_capabilities": "范围,每行一项:标签 = ready | needed | stretch", + "admin_requests_signal": "信号", + "admin_requests_sponsor_url": "赞助链接", + "admin_requests_save": "保存", + "admin_requests_saved": "已保存", + "requests_created_pending": "请求已提交。管理员批准后将公开显示。", + "requests_status_pending": "审核中", + "requests_status_rejected": "已拒绝", + "requests_status_in_progress": "进行中", + "requests_status_shipped": "已发布", + "requests_view_plugin": "查看插件", + "requests_repo": "仓库", + "requests_start_building": "开始开发", + "requests_scaffold_title": "开发 {name}", + "requests_scaffold_intro": "创建一个 GitHub 仓库,其中包含由 @tabularis/create-plugin 生成的插件。插件以该请求命名,一旦上架,请求即自动完成。", + "requests_scaffold_repo_name": "仓库名称", + "requests_scaffold_owner": "组织", + "requests_scaffold_owner_hint": "你的账户", + "requests_scaffold_private": "私有仓库", + "requests_scaffold_db_type": "连接类型", + "requests_scaffold_db_network": "网络(主机、端口、用户)", + "requests_scaffold_db_file": "文件", + "requests_scaffold_db_folder": "文件夹", + "requests_scaffold_db_api": "API(无连接表单)", + "requests_scaffold_with_ui": "包含 UI 扩展", + "requests_scaffold_min_runtime": "最低 Tabularis 版本", + "requests_scaffold_local": "更想在本地开始?", + "requests_scaffold_create": "创建仓库", + "requests_scaffold_creating": "正在创建…", + "requests_scaffold_grant": "授予权限", + "requests_scaffold_done": "{repo} 已就绪。", + "requests_scaffold_next": "开发插件、发布一个 release,然后将仓库提交到注册中心。", + "requests_scaffold_open_repo": "打开仓库", + "requests_scaffold_submit": "提交到注册中心" } diff --git a/apps/frontend/src/lib/components/requests/ScaffoldDialog.svelte b/apps/frontend/src/lib/components/requests/ScaffoldDialog.svelte new file mode 100644 index 00000000..e08db0b8 --- /dev/null +++ b/apps/frontend/src/lib/components/requests/ScaffoldDialog.svelte @@ -0,0 +1,189 @@ + + + + + + +
+ + + +
+ + {m.requests_scaffold_title({ name: request.name })} + + + {m.requests_scaffold_intro()} + +
+
+ + {#if result} +
+

{m.requests_scaffold_done({ repo: result.fullName })}

+

{m.requests_scaffold_next()}

+
+ + +
+
+ {:else} +
+
+
+ + +
+
+ + +
+
+ + {#if isTheme} +
+ + +
+ {:else} +
+ + +
+ + {/if} + + + {#if error} + + {/if} + +
+

{m.requests_scaffold_local()}

+ +
+ +
+ + +
+
+ {/if} +
+
+
diff --git a/apps/frontend/src/lib/types.ts b/apps/frontend/src/lib/types.ts index e88d7735..253f6d50 100644 --- a/apps/frontend/src/lib/types.ts +++ b/apps/frontend/src/lib/types.ts @@ -139,6 +139,33 @@ export type PageRendered = { updatedAt: number } +export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'fulfilled' + +export type BoardStatus = 'open' | 'most-wanted' | 'scoped' | 'coming-soon' + +export type RequestBounty = { + boardStatus?: BoardStatus + tagline?: string + stage?: string + nextStep?: string + difficulty?: 'Low' | 'Medium' | 'High' | 'Extreme' + accent?: string + radar?: { x: number; y: number; scale: number } + tags?: string[] + capabilities?: { label: string; state: 'ready' | 'needed' | 'stretch' }[] + signal?: string + sponsorUrl?: string +} + +export type RequestClaimer = { + userId: string + username: string | null + displayName: string + avatarUrl: string | null + repoUrl: string | null + claimedAt: number +} + export type PluginRequest = { id: string slug: string @@ -146,10 +173,18 @@ export type PluginRequest = { description: string kind: string | null requesterId: string + status: RequestStatus + rejectionReason: string | null + bounty: RequestBounty | null upvotes: number createdAt: number + updatedAt: number | null + fulfilledAt: number | null + fulfilledPlugin: { id: string; name: string } | null claims: number + claimers: RequestClaimer[] claimedByMe: boolean + votedByMe: boolean } export type SubmittableRepo = { diff --git a/apps/frontend/src/routes/admin/+layout.svelte b/apps/frontend/src/routes/admin/+layout.svelte index 223a39f7..b7aa4f37 100644 --- a/apps/frontend/src/routes/admin/+layout.svelte +++ b/apps/frontend/src/routes/admin/+layout.svelte @@ -21,6 +21,7 @@ import KeyRound from '@lucide/svelte/icons/key-round' import Webhook from '@lucide/svelte/icons/webhook' import Languages from '@lucide/svelte/icons/languages' + import Lightbulb from '@lucide/svelte/icons/lightbulb' import Menu from '@lucide/svelte/icons/menu' import X from '@lucide/svelte/icons/x' import { auth } from '$lib/stores/auth.svelte' @@ -30,6 +31,7 @@ let { children } = $props() let gated = $state(true) let pendingCount = $state(0) + let pendingRequests = $state(0) let drawerOpen = $state(false) onMount(async () => { @@ -57,12 +59,20 @@ } catch { // ignore } + try { + const { data, error } = await eden.api.requests.get({ query: { status: 'pending', limit: '1' } }) + if (error) throw error + pendingRequests = (data as { total: number }).total + } catch { + // ignore + } }) const sections = $derived([ { href: '/admin', label: m.admin_nav_overview(), icon: LayoutDashboard, badge: 0 }, { href: '/admin/providers', label: m.admin_nav_providers(), icon: Plug, badge: 0 }, { href: '/admin/plugins', label: m.admin_nav_plugins(), icon: Boxes, badge: pendingCount }, + { href: '/admin/requests', label: m.admin_nav_requests(), icon: Lightbulb, badge: pendingRequests }, { href: '/admin/users', label: m.admin_nav_users(), icon: UsersRound, badge: 0 }, { href: '/admin/pages', label: m.admin_nav_pages(), icon: FileText, badge: 0 }, { href: '/admin/infra', label: m.admin_nav_infrastructure(), icon: ServerCog, badge: 0 }, diff --git a/apps/frontend/src/routes/admin/requests/+page.svelte b/apps/frontend/src/routes/admin/requests/+page.svelte new file mode 100644 index 00000000..e35a4df0 --- /dev/null +++ b/apps/frontend/src/routes/admin/requests/+page.svelte @@ -0,0 +1,315 @@ + + +
+ + {#snippet actions()} + load()} size="sm" /> + {/snippet} + + + {#if loading} + {#each Array(3) as _} + + {/each} + {:else if requests.length === 0} +
+ {m.admin_requests_empty()} +
+ {:else} +
    + {#each requests as r (r.id)} + {@const draft = drafts[r.id]} +
  • + + +
    +
    +
    +

    {r.name}

    + {r.slug} + {#if r.kind}{r.kind}{/if} + {#if r.status === 'approved' && r.claims > 0} + {m.requests_status_in_progress()} + {/if} +
    +

    {r.description}

    +
    + {r.upvotes} + {r.claims} + {new Date(r.createdAt).toLocaleDateString()} + {#if r.fulfilledPlugin} + {r.fulfilledPlugin.name} + {/if} +
    +
    +
    + {#if r.status !== 'approved' && r.status !== 'fulfilled'} + + {/if} + {#if r.status === 'pending' || r.status === 'approved'} + + {/if} + {#if r.status === 'rejected'} + + {/if} +
    +
    + + {#if r.status === 'rejected' && r.rejectionReason} +

    + {m.admin_requests_reject_reason()}: + {r.rejectionReason} +

    + {/if} + + {#if expanded === r.id && draft} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +