Skip to content
Closed
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
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
build:
name: Build (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18.x, 20.x]

steps:
- name: Checkout
uses: actions/checkout@v4

# pnpm 10 is required for the workspace config (`allowBuilds`,
# `minimumReleaseAgeExclude`). See VERSIONS.md for the supported range.
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm

- name: Install dependencies
run: pnpm install

# The access-api types reference the generated Prisma client.
- name: Generate Prisma client
run: pnpm --filter @guildpass/access-api prisma:generate

# Shared packages must be compiled before typechecking/building the apps
# that consume them (@guildpass/env has no `prepare` script, and the
# apps resolve `@guildpass/*` packages from their `dist` output).
- name: Build workspace packages
run: pnpm -r --filter "./packages/*" build

- name: Type check
run: pnpm typecheck

# Build each app with its own build system (Next.js, Docusaurus, tsc).
- name: Build apps
run: pnpm -r --filter "./apps/*" build
env:
NEXT_TELEMETRY_DISABLED: "1"
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ By participating you agree to our [Code of Conduct](./CODE_OF_CONDUCT.md). Pleas

### Prerequisites

- **Node.js** 18.17 or higher
- **pnpm** 9+ (install via `npm install -g pnpm`)
- **Node.js** 18.17 or higher (recommended: 20.x)
- **pnpm** 9+ (install via `npm install -g pnpm`; recommended: 10.x)

> See [`VERSIONS.md`](./VERSIONS.md) for the authoritative Node.js, pnpm,
> TypeScript, and framework version matrix.
- A **Discord application** (bot token + `applications.commands` scope) β€” only needed if working on the Discord bot

### Quick Start (Dashboard)
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ GuildPass is a web dashboard for managing access, passes, guilds/communities, me

## Prerequisites

- **Node.js** 18.17 or later
- **pnpm** (install via `npm install -g pnpm`)
- **Node.js** 18.17 or later (recommended: 20.x)
- **pnpm** 9 or later (recommended: 10.x)

See [VERSIONS.md](./VERSIONS.md) for the full, up-to-date dependency matrix
(Node.js, pnpm, TypeScript, and each app's framework versions).

---

Expand Down
85 changes: 85 additions & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Dependency Versions

This document records the toolchain and framework versions that the GuildPass
monorepo is built and tested against. It exists to keep the three app build
systems (Next.js, Docusaurus, and the Node.js TypeScript services) compatible
with the shared workspace packages.

> πŸ’‘ The monorepo's `engines` fields and CI matrix are the source of truth for
> what is *supported*. The "recommended" column reflects what the production
> Docker images and CI builds actually use.

---

## Node.js

| | Version |
| --- | --- |
| Minimum | `18.17.0` (18.x) |
| Recommended | `20.x` |

All apps and packages declare `"engines": { "node": ">=18.17.0" }`. The
production Dockerfiles (`apps/*/Dockerfile`) build and run on `node:20-alpine`.

---

## pnpm

| | Version |
| --- | --- |
| Minimum | `9.x` |
| Recommended | `10.x` |

- `apps/*/Dockerfile` enable and pin `pnpm@9` via Corepack.
- `pnpm-workspace.yaml` uses pnpm 10 options (`allowBuilds`,
`minimumReleaseAgeExclude`), so **pnpm 10 is recommended** for local
development.
- The lockfile is intentionally gitignored (see `verify-lockfile=false` in
`.pnpmrc`).

---

## TypeScript

| | Version |
| --- | --- |
| Range | `^5.4.0` (workspace packages) Β· `^5.6.3` (dashboard, access-api, env) |

The workspace base config is [`tsconfig.base.json`](./tsconfig.base.json)
(`strict`, `NodeNext` module resolution, `ES2020` target). Apps override the
target where needed:

- `apps/dashboard` β€” `moduleResolution: "bundler"`, JSX `preserve` (Next.js).
- `apps/access-api` β€” `ES2022` target, NodeNext ESM.
- `apps/discord-bot` β€” NodeNext ESM, `rootDir: src`.

---

## App-specific versions

| App | Framework / runtime | Version |
| --- | --- | --- |
| `apps/dashboard` | Next.js | `14.2.21` (14.x) |
| `apps/dashboard` | React / React DOM | `18.3.1` |
| `apps/docs` | Docusaurus | `^3.2.1` (3.x) |
| `apps/docs` | React / React DOM | `^18.2.0` |
| `apps/access-api` | Node.js built-in `http` server | β€” (no web framework) |
| `apps/access-api` | Prisma | `^5.15.0` |
| `apps/access-api` | viem | `^2.13.0` |
| `apps/discord-bot` | discord.js | `^14.15.3` (14.x) |
| `apps/discord-bot` | @discordjs/rest | `^2.6.1` |

> **Note:** despite historical references to "Fastify", `apps/access-api` does
> not use Fastify. It uses Node's built-in `http` module for its health
> endpoint. See [`apps/access-api/README.md`](./apps/access-api/README.md).

---

## Update Policy

- **Major version updates** (e.g. Next.js 14 β†’ 15, Docusaurus 3 β†’ 4, Node 20 β†’
22) require approval from the core team and must be accompanied by:
- a passing CI run on the full Node version matrix, and
- updated documentation in this file and the relevant `README.md`.
- Minor and patch updates may be applied independently, but must keep every app
and package building with `pnpm typecheck`, `pnpm lint`, and `pnpm test`.
20 changes: 17 additions & 3 deletions apps/access-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ The Access API contains the on-chain event indexer for GuildPass membership stat
1. [Architecture Overview](#architecture-overview)
2. [Environment Setup](#environment-setup)
3. [Running the Indexer](#running-the-indexer)
4. [Database Schema](#database-schema)
5. [Backfill / Replay Runbook](#backfill--replay-runbook)
6. [Development & Testing](#development--testing)
4. [TypeScript, ESM & Node.js](#typescript-esm--nodejs)
5. [Database Schema](#database-schema)
6. [Backfill / Replay Runbook](#backfill--replay-runbook)
7. [Development & Testing](#development--testing)

---

Expand Down Expand Up @@ -80,6 +81,19 @@ The indexer polls every 10 seconds. Logs are written to stdout.

---

## TypeScript, ESM & Node.js

The Access API is a plain **Node.js `http` server** (no web framework such as Fastify/Express) compiled to **ECMAScript modules** targeting **Node.js 18+**.

- **Module system:** `"type": "module"` in `package.json`, combined with `module`/`moduleResolution: "NodeNext"` (inherited from the workspace root [`tsconfig.base.json`](../../tsconfig.base.json)). Relative imports therefore use explicit `.js` extensions, as required by NodeNext ESM.
- **Compile target:** `ES2022` β€” the language level fully supported by Node.js 18+.
- **Build output:** `tsc` compiles `src/**/*.ts` β†’ `dist/**/*.js`, with `src/index.ts` emitting `dist/index.js` (the entry point used by both `pnpm start` and the production Docker image).
- **Dev / scripts / tests:** Run directly from TypeScript via `tsx` (`pnpm dev`, `pnpm backfill`, `pnpm test`) β€” no build step is required for local development.

Required runtimes are documented in [`VERSIONS.md`](../../VERSIONS.md).

---

## Database Schema

| Table | Purpose |
Expand Down
18 changes: 18 additions & 0 deletions apps/access-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ model ProcessedEvent {
status String @default("processed") // e.g., processed, reverted
eventType String
data Json
previousState Json? // membership state before this event was applied
fencingToken Int @default(0) // Monotonically increasing leader generation
createdAt DateTime @default(now())

Expand Down Expand Up @@ -54,6 +55,23 @@ model BackfillLock {
updatedAt DateTime @updatedAt
}

/// Dead-letter queue for events that could not be processed (for retry / triage).
model FailedEvent {
id String @id @default(uuid())
contractAddress String
blockHash String
blockNumber BigInt
transactionHash String
logIndex Int
eventType String
error String
data Json
retryCount Int @default(0)
createdAt DateTime @default(now())

@@index([contractAddress])
}

/// Singleton row (id = "singleton") used for distributed leader election
/// across horizontally-scaled indexer instances.
model LeaderElection {
Expand Down
2 changes: 1 addition & 1 deletion apps/access-api/src/utils/backfill-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export class BackfillLock {
/** Returns all currently-held (possibly stale) locks. */
async listLocks(): Promise<LockInfo[]> {
const rows = await this.prisma.backfillLock.findMany();
return rows.map((r: { holder: string; acquiredAt: Date; liveHead: Date | null }) => ({
return rows.map((r) => ({
holder: r.holder as LockHolder,
acquiredAt: r.acquiredAt,
liveHead: r.liveHead ?? undefined,
Expand Down
2 changes: 1 addition & 1 deletion apps/access-api/src/workers/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export class IndexerCore {
}
}

private async applyEventApplication(decoded: any, tx: any) {
private async applyEventApplication(decoded: any, tx: any): Promise<any> {
const { eventName, args } = decoded;

if (eventName === MEMBERSHIP_EVENTS.MembershipCreated) {
Expand Down
6 changes: 4 additions & 2 deletions apps/access-api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"target": "ES2022",
"lib": ["ES2022"],
"rootDir": "src",
"outDir": "dist",
"composite": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "scripts", "test"]
"include": ["src"]
}
1 change: 1 addition & 0 deletions apps/dashboard/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const nextConfig = {
transpilePackages: [
"@guildpass/env",
"@guildpass/integration-client",
"@guildpass/metrics",
"@guildpass/webhook-utils",
],

Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/test/activity-hash-chain-durable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ if (!connectionString) {
const repository = new DurableActivityRepository(connectionString);
const storage = new DurableActivityStorage({ ttlSeconds: 3600 });

await repository.append({
await repository.append("test-guild", {
type: "member.joined",
source: "dashboard",
severity: "info",
Expand All @@ -86,7 +86,7 @@ if (!connectionString) {
),
"recorded",
);
await repository.append({
await repository.append("test-guild", {
type: "pass.created",
source: "dashboard",
severity: "info",
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/test/activity-hash-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("activity hash-chain canonical format", () => {

test("mock repository events remain unhashed application events", async () => {
const repository = new DurableActivityRepository("mock://activity-chain");
const event = await repository.append({
const event = await repository.append("test-guild", {
type: "member.joined",
source: "dashboard",
severity: "info",
Expand Down
5 changes: 1 addition & 4 deletions apps/dashboard/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@
"baseUrl": ".",
"ignoreDeprecations": "5.0",
"paths": {
"@/*": ["./*"],
"@guildpass/env": ["../../packages/env/src/index.ts"],
"@guildpass/integration-client/*": ["../../packages/integration-client/*"],
"@guildpass/mock-repositories": ["../../packages/integration-client/mock/mockRepositories"]
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
Expand Down
2 changes: 1 addition & 1 deletion apps/discord-bot/src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Client, GatewayIntentBits, Events } from "discord.js";
import { config, validateConfig } from "./config.js";
import { config } from "./config.js";
import { RoleReconciliationQueue } from "./queue.js";
import { reconcileMemberRoles, resolveDesiredRoles, type RoleMap } from "./roles.js";
import { handleGuildStats } from "./commands/guild-stats.js";
Expand Down
2 changes: 1 addition & 1 deletion apps/discord-bot/test/member-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ describe("MemberReconciliationLock", () => {

it("never interleaves read-modify-write cycles for the same member", async () => {
const operationLog: { phase: string; time: number }[] = [];
let currentRoles = new Set<string>();
const currentRoles = new Set<string>();

const state: MockRoleState = {
currentRoles: currentRoles,
Expand Down
6 changes: 0 additions & 6 deletions apps/discord-bot/test/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ function delayedTask<T>(ms: number, value: T): () => Promise<T> {
return () => new Promise((r) => setTimeout(() => r(value), ms));
}

/** Create a task that rejects after `ms`. */
function failingTask(ms: number, error: unknown): () => Promise<never> {
return () =>
new Promise((_, reject) => setTimeout(() => reject(error), ms));
}

/** Collect all metrics events into an array. */
function collectMetrics(): { events: QueueMetrics[]; handler: MetricsHandler } {
const events: QueueMetrics[] = [];
Expand Down
6 changes: 5 additions & 1 deletion apps/docs/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ const config = {
organizationName: "GuildPass",
projectName: "guildpass-integrations",
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
markdown: {
hooks: {
onBrokenMarkdownLinks: "warn",
},
},
i18n: { defaultLocale: "en", locales: ["en"] },
presets: [
[
Expand Down
18 changes: 17 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,23 @@ export default tseslint.config(
module: "readonly",
process: "readonly",
__dirname: "readonly",
console: "readonly"
console: "readonly",
// Web/fetch API globals available in Node 18+ (used by tests and scripts)
fetch: "readonly",
Response: "readonly",
Request: "readonly",
Headers: "readonly",
URL: "readonly",
URLSearchParams: "readonly",
AbortController: "readonly",
AbortSignal: "readonly",
TextEncoder: "readonly",
TextDecoder: "readonly",
structuredClone: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ export interface JsonRpcResponse<T = any> {
id: number | string;
}

export interface ContractCallOptions extends HttpRequestOptions {}
export type ContractCallOptions = HttpRequestOptions;
Loading