From 1edc5e4fa2f696bb7af20d6cc239d4f4734eccc7 Mon Sep 17 00:00:00 2001 From: Truphile Date: Sat, 27 Jun 2026 22:24:03 -0400 Subject: [PATCH 1/5] feat: add openapi specification generation --- .github/workflows/openapi.yml | 36 ++++ README.md | 17 ++ apps/access-api/package.json | 1 + apps/access-api/scripts/generate-openapi.ts | 28 ++++ apps/access-api/src/routes.ts | 2 +- docs/openapi.json | 174 ++++++++++++++++++++ 6 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/openapi.yml create mode 100644 apps/access-api/scripts/generate-openapi.ts create mode 100644 docs/openapi.json diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 0000000..fe21032 --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,36 @@ +name: OpenAPI Validation + +on: + push: + branches: [ main ] + pull_request: + +jobs: + validate-openapi: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate OpenAPI spec + run: pnpm --filter access-api run openapi:generate + + - name: Verify no changes + run: | + if [[ -n $(git status --porcelain docs/openapi.json) ]]; then + echo "Error: docs/openapi.json is out of date. Run 'pnpm --filter access-api run openapi:generate' (or npm run openapi:generate in apps/access-api) and commit the changes." + git diff docs/openapi.json + exit 1 + fi diff --git a/README.md b/README.md index d864d62..c5bf311 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,23 @@ Responses include `allowed`/`denied` plus human-readable and machine-readable re --- +## OpenAPI Specification + +A stable, machine-readable OpenAPI specification is generated for all public API routes to support SDKs and integrations. + +- **Specification File:** [docs/openapi.json](./docs/openapi.json) + +**For Contributors:** +When adding or modifying routes in the Access API, you must update the checked-in specification. Run the following command from the root of the repository: + +```bash +npm run -w access-api openapi:generate +``` + +CI will automatically verify that the OpenAPI specification is up-to-date with your code changes. + +--- + ## Data Model Prisma schema includes: `communities`, `wallets`, `members`, `memberships`, `roles`, `access policies`, `profiles`, `badges` (placeholder). diff --git a/apps/access-api/package.json b/apps/access-api/package.json index 9294af2..061d342 100644 --- a/apps/access-api/package.json +++ b/apps/access-api/package.json @@ -10,6 +10,7 @@ "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint 'src/**/*.ts'", + "openapi:generate": "ts-node scripts/generate-openapi.ts", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "seed": "ts-node prisma/seed.ts", diff --git a/apps/access-api/scripts/generate-openapi.ts b/apps/access-api/scripts/generate-openapi.ts new file mode 100644 index 0000000..93495d4 --- /dev/null +++ b/apps/access-api/scripts/generate-openapi.ts @@ -0,0 +1,28 @@ +import fs from 'fs'; +import path from 'path'; +import { buildApp } from '../src/app'; + +async function main() { + const app = await buildApp(); + await app.ready(); + + const openapiSpec = app.swagger(); + + // Write to repository root docs folder + const outPath = path.join(__dirname, '../../../docs/openapi.json'); + const outDir = path.dirname(outPath); + + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + fs.writeFileSync(outPath, JSON.stringify(openapiSpec, null, 2)); + console.log(`OpenAPI specification successfully written to ${outPath}`); + + await app.close(); +} + +main().catch((err) => { + console.error('Failed to generate OpenAPI specification:', err); + process.exit(1); +}); diff --git a/apps/access-api/src/routes.ts b/apps/access-api/src/routes.ts index 2ca821e..639d71c 100644 --- a/apps/access-api/src/routes.ts +++ b/apps/access-api/src/routes.ts @@ -30,7 +30,7 @@ export async function registerRoutes(app: FastifyInstance): Promise { // POST /v1/access/check — check access for wallet/resource app.post('/v1/access/check', async (request, reply) => { const body = request.body as { - wallet: string; + wallet: `0x${string}`; communityId: string; resource: string; }; diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..ad4f856 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,174 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "GuildPass Access API", + "description": "MVP API for wallet membership and access checks", + "version": "0.1.0" + }, + "components": { + "schemas": {} + }, + "paths": { + "/metrics": { + "get": { + "summary": "Prometheus metrics scrape endpoint", + "tags": [ + "Observability" + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/health/live": { + "get": { + "summary": "Liveness probe – is the process alive?", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "version": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/health/ready": { + "get": { + "summary": "Readiness probe – can we serve traffic?", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "db": { + "type": "string" + } + } + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "db": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/memberships/{wallet}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "wallet", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/v1/members/{wallet}": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "wallet", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/v1/access/check": { + "post": { + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, + "/v1/communities/{communityId}/members": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "communityId", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + } + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ] +} \ No newline at end of file From 70b8916eb3e7cdc775ae750b96c7fea03b07360e Mon Sep 17 00:00:00 2001 From: Truphile Date: Sat, 27 Jun 2026 22:30:08 -0400 Subject: [PATCH 2/5] fix(ci): build packages before generating openapi spec --- .github/workflows/openapi.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index fe21032..2fdffdf 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -24,6 +24,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build packages + run: pnpm --filter "./packages/**" run build + - name: Generate OpenAPI spec run: pnpm --filter access-api run openapi:generate From 5cc2628848b2252190318ff9eecaf4bf61e49b2b Mon Sep 17 00:00:00 2001 From: Truphile Date: Sat, 27 Jun 2026 22:35:41 -0400 Subject: [PATCH 3/5] fix(prisma): add missing initial migration --- .../20260615000000_init/migration.sql | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 apps/access-api/prisma/migrations/20260615000000_init/migration.sql diff --git a/apps/access-api/prisma/migrations/20260615000000_init/migration.sql b/apps/access-api/prisma/migrations/20260615000000_init/migration.sql new file mode 100644 index 0000000..a0b6351 --- /dev/null +++ b/apps/access-api/prisma/migrations/20260615000000_init/migration.sql @@ -0,0 +1,122 @@ +-- CreateEnum +CREATE TYPE "MembershipState" AS ENUM ('invited', 'active', 'expired', 'suspended'); + +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('admin', 'member', 'contributor'); + +-- CreateEnum +CREATE TYPE "RoleSource" AS ENUM ('manual', 'auto'); + +-- CreateTable +CREATE TABLE "Community" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Community_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Wallet" ( + "id" TEXT NOT NULL, + "address" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Profile" ( + "id" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "bio" TEXT, + + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Member" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "profileId" TEXT, + + CONSTRAINT "Member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Membership" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "state" "MembershipState" NOT NULL, + "expiresAt" TIMESTAMP(3), + "renewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Membership_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoleAssignment" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "role" "Role" NOT NULL, + "source" "RoleSource" NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RoleAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AccessPolicy" ( + "id" TEXT NOT NULL, + "communityId" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "rule" TEXT NOT NULL, + + CONSTRAINT "AccessPolicy_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Badge" ( + "id" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Badge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Wallet_address_key" ON "Wallet"("address"); + +-- CreateIndex +CREATE UNIQUE INDEX "Member_communityId_walletId_key" ON "Member"("communityId", "walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Membership_memberId_key" ON "Membership"("memberId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccessPolicy_communityId_resource_key" ON "AccessPolicy"("communityId", "resource"); + +-- AddForeignKey +ALTER TABLE "Member" ADD CONSTRAINT "Member_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Member" ADD CONSTRAINT "Member_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Member" ADD CONSTRAINT "Member_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "Profile"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccessPolicy" ADD CONSTRAINT "AccessPolicy_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "Community"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Badge" ADD CONSTRAINT "Badge_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + From b71086ee146e34bbb4d194be6d48ee2c992a62bf Mon Sep 17 00:00:00 2001 From: Truphile Date: Sat, 27 Jun 2026 22:39:45 -0400 Subject: [PATCH 4/5] fix(ci): run prisma generate to fix implicit any TS errors --- .github/workflows/openapi.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 2fdffdf..969a527 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -27,6 +27,9 @@ jobs: - name: Build packages run: pnpm --filter "./packages/**" run build + - name: Generate Prisma Client + run: pnpm --filter access-api run prisma:generate + - name: Generate OpenAPI spec run: pnpm --filter access-api run openapi:generate From aaea02441dfbe5894d53efbb4eae2b5c2fa31922 Mon Sep 17 00:00:00 2001 From: Truphile Date: Sat, 27 Jun 2026 22:51:25 -0400 Subject: [PATCH 5/5] feat(access-api): namespace member routes under communityId --- apps/access-api/prisma/schema.prisma | 2 +- apps/access-api/src/routes.ts | 12 +++++------ .../test/routes.integration.test.ts | 12 +++++------ docs/openapi.json | 20 +++++++++++++++++-- packages/shared-types/src/apiContract.ts | 8 ++++---- 5 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/access-api/prisma/schema.prisma b/apps/access-api/prisma/schema.prisma index 257d9dd..02243f2 100644 --- a/apps/access-api/prisma/schema.prisma +++ b/apps/access-api/prisma/schema.prisma @@ -35,7 +35,7 @@ model Member { profile Profile? @relation(fields: [profileId], references: [id]) membership Membership? roles RoleAssignment[] - // badges Badge[] + badges Badge[] @@unique([communityId, walletId]) } diff --git a/apps/access-api/src/routes.ts b/apps/access-api/src/routes.ts index 639d71c..14c1acd 100644 --- a/apps/access-api/src/routes.ts +++ b/apps/access-api/src/routes.ts @@ -10,16 +10,16 @@ export async function registerRoutes(app: FastifyInstance): Promise { const prisma = getPrisma(); const memberService = getMemberService(prisma); - // GET /v1/memberships/:wallet — list membership communities for a wallet - app.get('/v1/memberships/:wallet', async (request, reply) => { - const { wallet } = request.params as { wallet: string }; + // GET /v1/communities/:communityId/memberships/:wallet — list membership communities for a wallet + app.get('/v1/communities/:communityId/memberships/:wallet', async (request, reply) => { + const { wallet } = request.params as { communityId: string; wallet: string }; const result = await memberService.getMembershipsByWallet(wallet); return result; }); - // GET /v1/members/:wallet — get member profile - app.get('/v1/members/:wallet', async (request, reply) => { - const { wallet } = request.params as { wallet: string }; + // GET /v1/communities/:communityId/members/:wallet — get member profile + app.get('/v1/communities/:communityId/members/:wallet', async (request, reply) => { + const { wallet } = request.params as { communityId: string; wallet: string }; const result = await memberService.getProfileByWallet(wallet); if (!result) { return reply.status(404).send({ error: 'Member not found' }); diff --git a/apps/access-api/test/routes.integration.test.ts b/apps/access-api/test/routes.integration.test.ts index 092211d..943d555 100644 --- a/apps/access-api/test/routes.integration.test.ts +++ b/apps/access-api/test/routes.integration.test.ts @@ -30,13 +30,13 @@ async function buildTestApp(mockService: ReturnType { - const { wallet } = request.params as { wallet: string }; + app.get('/v1/communities/:communityId/memberships/:wallet', async (request) => { + const { wallet } = request.params as { communityId: string; wallet: string }; return mockService.getMembershipsByWallet(wallet); }); - app.get('/v1/members/:wallet', async (request, reply) => { - const { wallet } = request.params as { wallet: string }; + app.get('/v1/communities/:communityId/members/:wallet', async (request, reply) => { + const { wallet } = request.params as { communityId: string; wallet: string }; const result = await mockService.getProfileByWallet(wallet); if (!result) { return reply.status(404).send({ error: 'Member not found' }); @@ -122,7 +122,7 @@ describe('GET /v1/memberships/:wallet', () => { const response = await app.inject({ method: 'GET', - url: '/v1/memberships/0x0000000000000000000000000000000000000000', + url: '/v1/communities/community-1/memberships/0x0000000000000000000000000000000000000000', }); expect(response.statusCode).toBe(200); @@ -161,7 +161,7 @@ describe('GET /v1/members/:wallet', () => { const response = await app.inject({ method: 'GET', - url: '/v1/members/0x0000000000000000000000000000000000000000', + url: '/v1/communities/community-1/members/0x0000000000000000000000000000000000000000', }); expect(response.statusCode).toBe(404); diff --git a/docs/openapi.json b/docs/openapi.json index ad4f856..f191e19 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -99,9 +99,17 @@ } } }, - "/v1/memberships/{wallet}": { + "/v1/communities/{communityId}/memberships/{wallet}": { "get": { "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "communityId", + "required": true + }, { "schema": { "type": "string" @@ -118,9 +126,17 @@ } } }, - "/v1/members/{wallet}": { + "/v1/communities/{communityId}/members/{wallet}": { "get": { "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "communityId", + "required": true + }, { "schema": { "type": "string" diff --git a/packages/shared-types/src/apiContract.ts b/packages/shared-types/src/apiContract.ts index 8e3c326..0b4b43d 100644 --- a/packages/shared-types/src/apiContract.ts +++ b/packages/shared-types/src/apiContract.ts @@ -1,8 +1,8 @@ export const API_CONTRACT = { membershipsByWallet: { method: 'GET', - pathTemplate: '/v1/memberships/:wallet', - samplePath: '/v1/memberships/0x1234567890abcdef1234567890abcdef12345678', + pathTemplate: '/v1/communities/:communityId/memberships/:wallet', + samplePath: '/v1/communities/community-1/memberships/0x1234567890abcdef1234567890abcdef12345678', successStatus: 200, successResponse: { wallet: '0x1234567890abcdef1234567890abcdef12345678', @@ -13,8 +13,8 @@ export const API_CONTRACT = { }, memberProfileByWallet: { method: 'GET', - pathTemplate: '/v1/members/:wallet', - samplePath: '/v1/members/0x1234567890abcdef1234567890abcdef12345678', + pathTemplate: '/v1/communities/:communityId/members/:wallet', + samplePath: '/v1/communities/community-1/members/0x1234567890abcdef1234567890abcdef12345678', successStatus: 200, successResponse: { communityId: 'community-1',