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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
4 changes: 2 additions & 2 deletions apps/api/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<area>:read` | Read an administrative area as a current admin |
| `admin:<area>: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.

Expand Down
4 changes: 3 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand Down
149 changes: 149 additions & 0 deletions apps/api/scripts/import-bounties.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { '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)
Original file line number Diff line number Diff line change
@@ -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`);
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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`);
42 changes: 30 additions & 12 deletions apps/api/src/db/schema.mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) => ({
Expand Down
42 changes: 30 additions & 12 deletions apps/api/src/db/schema.pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) => ({
Expand Down
Loading
Loading