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

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
lint-and-typecheck:
name: Lint & Typecheck
runs-on: ubuntu-latest
timeout-minutes: 15

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

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20

- name: Install dependencies
# No lockfile is tracked (lock files are gitignored), so pnpm resolves
# versions from the workspace manifests.
run: pnpm install

- name: Lint
run: pnpm lint

- name: Typecheck
run: pnpm typecheck
1 change: 1 addition & 0 deletions apps/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"test": "tsx --test test/**/*.test.ts",
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? // pre-application state snapshot (used for exact rollback)
fencingToken Int @default(0) // Monotonically increasing leader generation
createdAt DateTime @default(now())

Expand All @@ -26,6 +27,23 @@ model ProcessedEvent {
@@index([blockHash])
}

/// Dead-letter queue: logs that failed processing, retryable via the backfill CLI.
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])
}


model Membership {
id String @id @default(uuid())
Expand Down
3 changes: 2 additions & 1 deletion apps/access-api/scripts/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,5 @@ async function main() {
}
}

main();
// main() handles its own errors (exit codes) β€” no await needed at top level
void main();
4 changes: 2 additions & 2 deletions apps/access-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ async function main() {
process.exit(0);
};

process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));

// ── Start indexing ─────────────────────────────────────────────────────
await indexer.start();
Expand Down
4 changes: 2 additions & 2 deletions apps/access-api/src/utils/backfill-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* a stale lock was erroneously held (e.g. after a crash).
*/

import { PrismaClient } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";

/** How long (ms) before a lock heartbeat is considered stale. */
const LOCK_TTL_MS = 60_000; // 1 minute
Expand Down 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: string; acquiredAt: Date; liveHead: bigint | null }) => ({
holder: r.holder as LockHolder,
acquiredAt: r.acquiredAt,
liveHead: r.liveHead ?? undefined,
Expand Down
33 changes: 16 additions & 17 deletions apps/access-api/src/utils/leader-election.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* - All coordination is database-backed β€” no external service required.
*/

import { PrismaClient } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { randomUUID } from "node:crypto";

// ─── Configuration ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -346,28 +346,27 @@ export class LeaderElectionService {

private startRenewLoop(): void {
this.clearTimers();
this.renewTimer = setInterval(async () => {
try {
await this.renewLease();
} catch (err) {
this.renewTimer = setInterval(() => {
void this.renewLease().catch((err) => {
console.error("[LeaderElection] Lease renewal error:", err);
}
});
}, this.renewIntervalMs);
}

private startPollLoop(): void {
this.clearTimers();
this.pollTimer = setInterval(async () => {
try {
const became = await this.tryBecomeLeader();
if (became) {
// Switch from polling to renewing
this.clearTimers();
this.startRenewLoop();
}
} catch (err) {
console.error("[LeaderElection] Poll error:", err);
}
this.pollTimer = setInterval(() => {
void this.tryBecomeLeader()
.then((became) => {
if (became) {
// Switch from polling to renewing
this.clearTimers();
this.startRenewLoop();
}
})
.catch((err) => {
console.error("[LeaderElection] Poll error:", err);
});
}, this.standbyPollIntervalMs);
}

Expand Down
37 changes: 33 additions & 4 deletions apps/access-api/src/workers/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
type Log,
} from "viem";
import { mainnet } from "viem/chains";
import { Prisma, PrismaClient } from "@prisma/client";
import { PrismaClient, type Prisma } from "@prisma/client";
import { MEMBERSHIP_ABI, MEMBERSHIP_EVENTS } from "@guildpass/contracts";
import { LeaderElectionService } from "../utils/leader-election.js";
import type { LeaderElectionService } from "../utils/leader-election.js";

export interface IndexerConfig {
rpcUrl: string;
Expand Down Expand Up @@ -279,21 +279,50 @@ export class IndexerCore {
}
}

private async applyEventApplication(decoded: any, tx: any) {
private async applyEventApplication(
decoded: any,
tx: any,
): Promise<{ wallet: string; passId: string; status: number } | undefined> {
const { eventName, args } = decoded;

if (eventName === MEMBERSHIP_EVENTS.MembershipCreated) {
const previous = await tx.membership.findUnique({
where: { wallet_passId: { wallet: args.member, passId: args.passId } },
});
await tx.membership.upsert({
where: { wallet_passId: { wallet: args.member, passId: args.passId } },
update: { status: 1 },
create: { wallet: args.member, passId: args.passId, status: 1 },
});
} else if (eventName === MEMBERSHIP_EVENTS.MembershipUpdated) {
// Snapshot the pre-application state so a reorg can roll back exactly
// (passId is BigInt in the DB, so serialise it for JSON storage).
return previous
? {
wallet: previous.wallet,
passId: previous.passId.toString(),
status: previous.status,
}
: undefined;
}

if (eventName === MEMBERSHIP_EVENTS.MembershipUpdated) {
const previous = await tx.membership.findUnique({
where: { wallet_passId: { wallet: args.member, passId: args.passId } },
});
await tx.membership.update({
where: { wallet_passId: { wallet: args.member, passId: args.passId } },
data: { status: args.newStatus },
});
return previous
? {
wallet: previous.wallet,
passId: previous.passId.toString(),
status: previous.status,
}
: undefined;
}

return undefined;
}

private async revertEventApplication(event: any, tx: any) {
Expand Down
50 changes: 2 additions & 48 deletions apps/dashboard/app/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import WalletAddressText from "@/components/WalletAddressText";
import { getActivityRefreshConfig } from "@/lib/env";
import { formatRelativeTime } from "@/lib/format-relative-time";
import { useActivityFeed } from "@/lib/hooks/useActivityFeed";
import { convertToCsv, downloadCsv, } from "@/lib/csv-export";
import {
type ActivityEventEntity,
type ActivityEventSeverity,
Expand Down Expand Up @@ -221,51 +220,6 @@ function ActivityPageContent() {
guildId,
});

const handleExportCsv = () => {
const exportData = events.map((activity) => ({
type: activity.type,
description: activity.description,
timestamp: new Date(activity.timestamp).toISOString(),
actor:
activity.actor.name ||
activity.actor.wallet ||
"System",
entity: activity.entity
? `${activity.entity.type}: ${
activity.entity.name || activity.entity.id
}`
: "",
source: activity.source,
severity: activity.severity,
}));

const columns: {
key:
| "type"
| "description"
| "timestamp"
| "actor"
| "entity"
| "source"
| "severity";
label: string;
}[] = [
{ key: "type", label: "Type" },
{ key: "description", label: "Description" },
{ key: "timestamp", label: "Timestamp" },
{ key: "actor", label: "Actor" },
{ key: "entity", label: "Entity" },
{ key: "source", label: "Source" },
{ key: "severity", label: "Severity" },
];

const csv = convertToCsv(exportData, columns);

const date = new Date().toISOString().split("T")[0];

downloadCsv(csv, `activity-log-${date}.csv`);
};

const hasActiveFilters = Boolean(type || source || severity || entityType || actor.trim() || from || sort !== "newest" || limit !== 10);

const clearFilters = () => {
Expand Down Expand Up @@ -294,7 +248,7 @@ function ActivityPageContent() {
</div>
<button
type="button"
onClick={refresh}
onClick={() => void refresh()}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
title="Fetch the latest activity events"
Expand Down Expand Up @@ -512,7 +466,7 @@ function ActivityPageContent() {
<div className="border-t border-slate-100 px-6 py-4 text-center">
<button
type="button"
onClick={loadMore}
onClick={() => void loadMore()}
disabled={loadingMore}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/api/activity/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NextResponse } from "next/server";
import type { NextResponse } from "next/server";
import { apiError, apiResponse, apiValidationError } from "@/lib/api-helpers";
import { filterActivityEvents, parseActivityQuery } from "@/lib/activity/query";
import { activityStorage } from "@/lib/activity/storage";
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/api/admin/reconcile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* Caller lacks guilds:write permission.
*/

import { NextResponse } from "next/server";
import type { NextResponse } from "next/server";
import { apiError, apiResponse, apiValidationError, handleApiError } from "@/lib/api-helpers";
import { requireDashboardSession, UnauthorizedError } from "@/lib/auth/server-session";
import { assertPermission, PermissionDeniedError } from "@/lib/permissions";
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/api/auth/nonce/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* Response: { ok: true, data: { nonce: string, expiresIn: number } }
* expiresIn is the nonce lifetime in seconds.
*/
import { NextResponse } from "next/server";
import type { NextResponse } from "next/server";
import { apiResponse } from "@/lib/api-helpers";
import { getNonceStore, NONCE_TTL_MS } from "@/lib/auth/nonce-store";

Expand Down
3 changes: 1 addition & 2 deletions apps/dashboard/app/api/auth/refresh/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* { accessToken: string, refreshToken: string, expiresIn: number }
*/

import { NextResponse } from "next/server";
import type { NextResponse } from "next/server";
import { apiError, apiResponse, apiValidationError } from "@/lib/api-helpers";
import { getSessionStore } from "@/lib/auth/server-session";
import { ACCESS_TOKEN_TTL, refreshSessionWithMetadata } from "@/lib/auth/session-store";
Expand Down Expand Up @@ -47,7 +47,6 @@ export async function POST(request: Request): Promise<NextResponse> {
let currentRole: Role = "readonly";

if (accessToken) {
const sessionStore = getSessionStore();
// Allow expired tokens for metadata extraction only.
try {
// Decode without expiry check by looking at the raw payload.
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/api/auth/revoke/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* admin/owner can revoke any session).
*/

import { NextResponse } from "next/server";
import type { NextResponse } from "next/server";
import { apiError, apiResponse, apiValidationError } from "@/lib/api-helpers";
import { requireDashboardSession, UnauthorizedError, getSessionStore } from "@/lib/auth/server-session";
import { assertPermission, PermissionDeniedError } from "@/lib/permissions";
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/api/auth/signin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* { accessToken: string, refreshToken: string, expiresIn: number }
*/

import { NextResponse } from "next/server";
import type { NextResponse } from "next/server";
import { apiError, apiResponse, apiValidationError } from "@/lib/api-helpers";
import { getSessionStore } from "@/lib/auth/server-session";
import { ACCESS_TOKEN_TTL } from "@/lib/auth/session-store";
Expand Down
Loading
Loading