GuildPass provides wallet-based membership and token-gated community infrastructure for the Web3 / EVM ecosystem.
This monorepo contains a runnable MVP backend and protocol foundation. It is intentionally not feature-complete, but is real, demoable, and extendable.
Part of the Adamantine-Guild project.
| Path | Purpose |
|---|---|
apps/access-api |
Fastify REST API (TypeScript, Prisma, PostgreSQL, OpenAPI) |
packages/contracts |
TypeScript helpers for on-chain contract addresses and ABIs |
packages/shared-types |
Shared types and enums for roles, membership, and decisions |
packages/policy-engine |
Simple, explainable access policy engine |
packages/sdk-lite |
Minimal HTTP client for the access API |
contracts/ |
Foundry Solidity project (MembershipNFT + tests + deploy scripts) |
- Node.js 18+
- npm 9+
- Docker (for PostgreSQL and Redis)
- Foundry (for Solidity contracts)
# 1. Clone and enter the repo
git clone https://github.com/Adamantine-Guild/guildpass-core.git
cd guildpass-core
# 2. Start PostgreSQL and Redis
docker compose up -d
# 3. Install dependencies
npm install
# 4. Set up environment variables
cp .env.example .env
# Edit .env — set DATABASE_URL, REDIS_URL, etc.
# 5. Generate Prisma client and run migrations
npm run -w access-api prisma:migrate
# 6. Seed the database with sample data
npm run seed
# 7. Start the API in development mode
npm run devOpenAPI docs available at: http://localhost:3000/docs
The MembershipNFT is a simple ERC-721 with expiry and suspension semantics, and admin-controlled mint/renew. It supports multi-community memberships, meaning a single deployed contract can represent memberships across multiple communities via the communityId mapping. Events emitted are suitable for off-chain indexing and include the associated communityId to easily map to the backend state.
# Build contracts
npm run contracts:build # runs: forge build
# Test contracts
npm run contracts:test # runs: forge test
# Deploy (example script)
npm run contracts:deploy # runs: forge script contracts/script/Deploy.s.sol --broadcastAfter deploying, set MEMBERSHIP_NFT_ADDRESS and CHAIN_ID in .env.
The GuildPass Access API follows a strict versioning and compatibility contract for all /v1 routes:
- Version Header: All API responses include an
x-guildpass-api-versionheader (e.g.,1.0.0) indicating the version being served. - Server Version: The
GET /health/liveendpoint exposes the current server API version. - Backwards Compatibility: We commit to maintaining backwards compatibility for all
/v1routes. We will not remove fields from responses or require new mandatory request parameters without bumping the major API version (e.g., to/v2). - Deprecation: If a
/v1route or field needs to be deprecated, we will serve adeprecation: trueheader on those responses and provide guidance in our documentation. Deprecated endpoints will continue to function for a minimum sunset period before removal. Clients are encouraged to monitor thedeprecationheader.
| Method | Path | Description |
|---|---|---|
| GET | /v1/memberships/:wallet |
Membership status summary by wallet |
| GET | /v1/members/:wallet |
Member profile (with membership and roles) |
| POST | /v1/access/check |
Access decision for { wallet, communityId, resource } |
| GET | /v1/communities/:communityId/members |
Admin member listing |
Responses include allowed/denied plus human-readable and machine-readable reasons.
A stable, machine-readable OpenAPI specification is generated for all public API routes to support SDKs and integrations.
- Specification File: 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:
npm run -w access-api openapi:generateCI will automatically verify that the OpenAPI specification is up-to-date with your code changes.
Prisma schema includes: communities, wallets, members, memberships, roles, access policies, profiles, badges (placeholder), audit_events, and outbox_events.
For an entity-relationship diagram and a per-table explanation of how these models connect (e.g. how memberships ties to wallets and communities, or how access policies reference roles), see docs/data-model.md.
The API uses the transactional outbox pattern to emit reliable integration events when domain state changes. Every mutation that affects memberships, roles, policies, resources, or access decisions writes a durable event to the OutboxEvent table within the same database transaction as the state change. This guarantees that no event is lost on request failure or process restart.
| Concept | Description |
|---|---|
| Event creation | Events are written atomically with the domain mutation inside a Prisma $transaction. If the mutation fails, no event is created. If the event write fails, the entire transaction rolls back. |
| Event types | MEMBERSHIP_CREATED, MEMBERSHIP_UPDATED, MEMBERSHIP_DELETED, ROLE_ASSIGNED, ROLE_REMOVED, RESOURCE_CREATED, RESOURCE_UPDATED, RESOURCE_ARCHIVED, POLICY_CREATED*, POLICY_UPDATED*, POLICY_DELETED*, ACCESS_DECISION, ACCESS_OVERRIDE_CREATED, ACCESS_OVERRIDE_UPDATED, ACCESS_OVERRIDE_REVOKED, MEMBER_ATTENDED, BADGE_ASSIGNED, BADGE_REVOKED |
| Statuses | pending (awaiting delivery), delivered (successfully processed), failed (permanently failed after max retries) |
| Retry strategy | Exponential backoff: nextRetryAt = now + 10 × 2^retryCount seconds. Default max 5 retries. |
| Delivery worker | outboxWorker polls for pending events every OUTBOX_WORKER_INTERVAL_MS (default 10s) and delegates to a pluggable handler. The default handler is a no-op logger. |
| Pruning | Delivered events older than 7 days are automatically pruned to prevent unbounded table growth. |
* POLICY_CREATED/POLICY_UPDATED/POLICY_DELETED are reserved for future CRUD on the base AccessPolicy (per-resource ruleType) record, which today is only read, not managed via an API. Wallet-specific access overrides (see Policy Engine, above) are a separate concept with their own ACCESS_OVERRIDE_* event types.
| Environment Variable | Default | Description |
|---|---|---|
OUTBOX_WORKER_INTERVAL_MS |
10000 |
Polling interval per shard (ms) |
OUTBOX_WORKER_BATCH_SIZE |
50 |
Max events per shard per poll |
OUTBOX_WORKER_COUNT |
1 |
Number of concurrent shards for horizontal scaling |
OUTBOX_WORKER_MIN_BATCH_SIZE |
5 |
Min batch size under backpressure |
The outbox worker accepts a custom OutboxEventHandler function. Replace the default no-op with your own delivery logic (HTTP webhook, NATS, Kafka, analytics pipeline, etc.):
import { createOutboxWorker, OutboxEventHandler } from './workers/outboxWorker';
const myHandler: OutboxEventHandler = async (event) => {
await fetch('https://hooks.example.com/integration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
};
const worker = createOutboxWorker({ intervalMs: 10_000, handler: myHandler });
worker.start();createWebhookHandler (apps/access-api/src/handlers/webhookHandler.ts) is a
ready-to-use OutboxEventHandler that delivers events as signed HTTP
webhooks to every active WebhookSubscription registered for a community,
filtered by event type:
import { createOutboxWorker } from './workers/outboxWorker';
import { createWebhookHandler } from './handlers/webhookHandler';
const worker = createOutboxWorker({ intervalMs: 10_000, handler: createWebhookHandler() });
worker.start();A WebhookSubscription row (communityId, url, secret, eventTypes[])
registers where a community's events should be delivered. An empty
eventTypes array means "all event types".
Every delivered request is HMAC-SHA256 signed with the subscription's per-community secret, and includes anti-replay fields:
| Header | Description |
|---|---|
X-GuildPass-Signature |
hex(HMAC_SHA256(secret, "${timestamp}.${nonce}.${body}")) |
X-GuildPass-Timestamp |
Unix epoch milliseconds when the request was signed |
X-GuildPass-Nonce |
Random UUID, unique per delivery attempt |
To verify a webhook on the receiving side:
- Recompute the HMAC from your stored secret, the received timestamp,
nonce, and raw request body, and compare it to
X-GuildPass-Signatureusing a constant-time comparison (never===on secrets/signatures). - Reject the request if
X-GuildPass-Timestampis older than your tolerance window (5 minutes recommended) — this bounds how long a captured request could be replayed. - Reject the request if you've already processed
X-GuildPass-Nonce(e.g. check-and-set it in Redis with a TTL matching your tolerance window) — the timestamp check alone does not prevent replay within the tolerance window.
verifyWebhookSignature in webhookHandler.ts is a reference
implementation of steps 1–2 (nonce tracking is necessarily your
application's responsibility, since it requires storage this library
doesn't own).
If any subscription delivery for an event fails (non-2xx response,
timeout, network error), the whole event is re-queued through the outbox's
existing exponential-backoff retry — the handler does not partially retry
just the failed subscriptions. Webhook consumers should therefore be
idempotent per (event.id, X-GuildPass-Nonce).
An event that exhausts the outbox's maxRetries (default 5) is no longer
just marked failed and pruned after 7 days — it's captured in
DeadLetterEvent with its failure reason and retry count, inspectable and
manually retriable via:
| Method | Path | Description |
|---|---|---|
| GET | /v1/communities/:communityId/dead-letter-events |
List dead-lettered events for a community, optionally filtered by ?status= |
| POST | /v1/communities/:communityId/dead-letter-events/:id/retry |
Re-enqueue a dead-lettered event as a fresh pending OutboxEvent |
| Metric | Type | Labels |
|---|---|---|
outbox_events_created_total |
Counter | event_type |
outbox_events_delivered_total |
Counter | event_type |
outbox_events_failed_total |
Counter | event_type |
Simple rules: PUBLIC, MEMBERS_ONLY, ADMINS_ONLY, CONTRIBUTORS_OR_ADMINS.
Role resolution combines:
- Membership state (adds
memberrole when active) - Backend role assignments (including custom role hierarchies and delegated grants)
- Manual access overrides — an explicit
ALLOW/DENYfor a single(wallet, communityId, resource)triple, e.g. a temporary ban or a one-off grant for a partner wallet. An active override is checked before any rule evaluation and, if present, short-circuits the decision — it takes precedence over every role- or membership-derived outcome. Overrides are managed via the/v1/communities/:communityId/overridesadmin routes below and expire automatically via their optionalexpiresAt.
Full spec (policy semantics, exact role-resolution algorithm, override precedence, worked examples): packages/policy-engine/README.md.
| Method | Path | Description |
|---|---|---|
| POST | /v1/communities/:communityId/overrides |
Create or update an override for a wallet/resource pair (admin only) |
| GET | /v1/communities/:communityId/overrides |
List overrides for a community, including expired ones (admin only) |
| DELETE | /v1/communities/:communityId/overrides/:wallet/:resource |
Revoke an override (admin only) |
Every mutation is written transactionally with an ACCESS_OVERRIDE_CREATED, ACCESS_OVERRIDE_UPDATED, or ACCESS_OVERRIDE_REVOKED outbox event (see below).
# All tests across workspaces
npm run test
# Policy engine unit tests
npm run -w @guildpass/policy-engine test
# Access API unit and integration tests
npm run -w access-api test
# Contract tests (Foundry)
npm run contracts:test
# TypeScript type checking
npm run typecheckWhen you change the Prisma schema or migration history, validate the database workflow locally before opening a pull request:
pnpm install --frozen-lockfile
createdb guildpass_test
createdb guildpass_shadow
DATABASE_URL=postgresql://localhost:5432/guildpass_test \
SHADOW_DATABASE_URL=postgresql://localhost:5432/guildpass_shadow \
pnpm --filter access-api prisma:validate
pnpm --filter access-api prisma:generate
pnpm --filter access-api prisma:migrate:deploy
pnpm --filter access-api prisma:migrate:checkThe CI workflow runs the same validation steps against a disposable PostgreSQL service and a shadow database so drift is caught before merge.
Not every schema change is safe to ship as a single direct migration —
see CONTRIBUTING.md > Database Migrations: Direct vs. Expand/Contract
for the decision framework, a worked example, and the reusable
batched-backfill utility (apps/access-api/src/services/backfillService.ts)
for populating large tables without holding long-running locks.
The Membership Integration Test (apps/access-api/src/membership-integration.test.ts) validates the complete flow from MembershipNFT contract events to API access decisions:
- Contract Events → Database State → Policy Engine → API Response
- Tests event ingestion (MembershipMinted, MembershipRenewed, MembershipSuspended)
- Validates active, expired, and suspended membership scenarios
- Proves access control decisions reflect actual membership state
- Can run locally without a live blockchain
See apps/access-api/INTEGRATION_TEST_GUIDE.md for detailed documentation.
This project uses ESLint to maintain code quality.
- Run linting for all packages:
npm run lint - Run linting for a specific package:
npm run lint -w <package-name>
See .env.example for all required variables.
- Advanced governance permissions
- Constitutional rule engine
- Complex moderation workflows / appeals / reinstatement
- Rich reward distribution and advanced streak logic
- Contribution scoring engine
- Full event attendance ingestion
- Multi-chain support (current: EVM only)
- Advanced indexing pipeline
Clear interfaces and TODOs are left where appropriate.
- Business logic lives in services and the policy engine, not route handlers.
- Contracts and API are aligned via shared types and simple event ABI.
- The code aims to be small and understandable; extending should not require rewrites.
We welcome contributions! See CONTRIBUTING.md for the full guide.
- Browse open issues tagged
good first issueorhelp wanted. - Comment directly on the GitHub issue you'd like to work on.
- Fork the repo, create a feature branch, implement your change, open a PR.
- Contact: cerealboxx123@gmail.com
MIT — see LICENSE.