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
13 changes: 13 additions & 0 deletions apps/api/src/services/proposals/onchainProposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export class ProposalsService {
* These statuses are computed at read time from PENDING/ACTIVE rows:
* - ACTIVE, DEFEATED, SUCCEEDED → stored as PENDING or ACTIVE in DB
* - EXPIRED, NO_QUORUM → stored as ACTIVE in DB (Azorius proposals)
* - QUEUED, PENDING_EXECUTION → stored QUEUED for governors with a queue
* event, but derived from a stored ACTIVE row for TORN (no queue event),
* so include ACTIVE as a candidate too.
*/
private prepareStatusForDatabase(statusArray: string[]): string[] {
const mappedStatuses = statusArray.flatMap((status) => {
Expand All @@ -55,6 +58,16 @@ export class ProposalsService {
ProposalStatus.QUEUED,
];
}
if (status === ProposalStatus.QUEUED) {
return [ProposalStatus.QUEUED, ProposalStatus.ACTIVE];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include PENDING for derived queued statuses

TORN proposals created before their voting window are persisted as PENDING, and TORNClient.getProposalStatus later derives QUEUED/PENDING_EXECUTION from that same row. With this prefilter, status=QUEUED only fetches QUEUED/ACTIVE rows, so those delayed TORN proposals are never hydrated and disappear from queued-status feeds; include PENDING here and in the PENDING_EXECUTION branch.

Useful? React with 👍 / 👎.

}
if (status === ProposalStatus.PENDING_EXECUTION) {
return [
ProposalStatus.PENDING_EXECUTION,
ProposalStatus.QUEUED,
ProposalStatus.ACTIVE,
];
}
if (
status === ProposalStatus.ACTIVE ||
status === ProposalStatus.DEFEATED ||
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/services/proposals/onchainProposals.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,33 @@ describe("ProposalsService", () => {
]);
});

it("should map QUEUED to QUEUED and ACTIVE for DB query", async () => {
// TORN derives QUEUED from a stored ACTIVE row (no queue event), so
// ACTIVE must be a candidate; QUEUED covers governors that store it.
await service.getProposals({
...DEFAULT_REQ,
status: [ProposalStatus.QUEUED],
});

expect(repo.lastStatusArg).toEqual([
ProposalStatus.QUEUED,
ProposalStatus.ACTIVE,
]);
});

it("should map PENDING_EXECUTION to PENDING_EXECUTION, QUEUED and ACTIVE for DB query", async () => {
await service.getProposals({
...DEFAULT_REQ,
status: [ProposalStatus.PENDING_EXECUTION],
});

expect(repo.lastStatusArg).toEqual([
ProposalStatus.PENDING_EXECUTION,
ProposalStatus.QUEUED,
ProposalStatus.ACTIVE,
]);
});

it("should filter by original status after chain check", async () => {
repo.proposals = [
createMockProposal({ id: "1" }),
Expand Down
18 changes: 17 additions & 1 deletion apps/authful/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import { collectPrometheusMetrics } from "@anticapture/observability";
import { OpenAPIHono as Hono } from "@hono/zod-openapi";
import { sql } from "drizzle-orm";
import { bearerAuth } from "hono/bearer-auth";

import type { AuthfulDrizzle } from "@/database";
import { tokensController } from "@/controllers/tokens";
import { validateController } from "@/controllers/validate";
import { exporter } from "@/instrumentation";
import { metricsMiddleware } from "@/middlewares/metrics";
import { requestLogger } from "@/middlewares/logger";
import { logger } from "@/logger";
import type { TokensService } from "@/services/tokens";

export type AppConfig = {
service: TokensService;
db: AuthfulDrizzle;
adminApiKey: string;
internalApiKey: string;
};

export function createApp({
service,
db,
adminApiKey,
internalApiKey,
}: AppConfig): Hono {
Expand All @@ -25,7 +30,18 @@ export function createApp({
app.use("*", requestLogger());
app.use("*", metricsMiddleware());

app.get("/health", (c) => c.json({ status: "ok" }));
// Railway healthcheck: probe the DB so a bad DATABASE_URL / down Postgres
// marks the service unhealthy instead of serving traffic that 500s on the
// first repository call (which also breaks Gateful auth for uncached tokens).
app.get("/health", async (c) => {
try {
await db.execute(sql`select 1`);
return c.json({ status: "ok" });
} catch (err) {
logger.error({ err }, "Health check DB probe failed");
return c.json({ status: "error" }, 503);
}
});

app.get("/metrics", async (c) => {
const { body, contentType } = await collectPrometheusMetrics(exporter);
Expand Down
15 changes: 15 additions & 0 deletions apps/authful/src/controllers/authful.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("authful app", () => {

app = createApp({
service: new TokensService(new TokensRepository(db)),
db,
adminApiKey: ADMIN_KEY,
internalApiKey: INTERNAL_KEY,
});
Expand Down Expand Up @@ -95,6 +96,20 @@ describe("authful app", () => {
expect(res.status).toBe(200);
});

it("returns 503 when the DB probe fails", async () => {
const failingDb = {
execute: () => Promise.reject(new Error("db down")),
} as unknown as AuthfulDrizzle;
const failingApp = createApp({
service: new TokensService(new TokensRepository(db)),
db: failingDb,
adminApiKey: ADMIN_KEY,
internalApiKey: INTERNAL_KEY,
});
const res = await failingApp.request("/health");
expect(res.status).toBe(503);
});

it("exposes Prometheus metrics publicly", async () => {
const res = await app.request("/metrics");
expect(res.status).toBe(200);
Expand Down
1 change: 1 addition & 0 deletions apps/authful/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ if (isPreview && env.SEED_TOKEN_PLAINTEXT) {

const app = createApp({
service,
db,
adminApiKey: env.ADMIN_API_KEY,
internalApiKey: env.INTERNAL_API_KEY,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,8 @@ export const ProposalCreationForm = ({
const ownsDraft = Boolean(
ownedDraft &&
address &&
ownedDraft.author.toLowerCase() === address.toLowerCase(),
// Legacy localStorage drafts predate the `author` field and may lack it.
ownedDraft.author?.toLowerCase() === address.toLowerCase(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve ownership for legacy local drafts

When the API migration/fetch fails, useDrafts falls back to legacy localStorage drafts that may not have author; those drafts still came from the connected wallet's per-address key. This optional check makes ownsDraft false for them, so after loading isRecipient becomes true and canShowEditor false, forcing the owner into the read-only shared preview for their own legacy draft instead of preserving edit access.

Useful? React with 👍 / 👎.

);
const isRecipient = Boolean(draftId) && !drafts.isLoading && !ownsDraft;
// Editor only for a new proposal or an owned draft (no load-dependent flash).
Expand Down Expand Up @@ -496,11 +497,14 @@ export const ProposalCreationForm = ({
: draftPreviewCopy({ role: "author" });

// Recipients can only ever see the Preview — they have no editor access.
// Gated on draftContentLoaded so a not-yet-resolved ownership check (drafts
// still loading on mount) can't misclassify an owner as a recipient and
// force their own draft into Preview.
useEffect(() => {
if (isRecipient && view !== "preview") {
if (isRecipient && draftContentLoaded && view !== "preview") {
void setView("preview");
}
}, [isRecipient, view, setView]);
}, [isRecipient, draftContentLoaded, view, setView]);

const proposalsListHref = `${basePath}/proposals`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ export const VotingModal = ({
const simulatedTotalVotes =
simulatedForVotes + simulatedAgainstVotes + simulatedAbstainVotes;

// Calculate simulated quorum votes (for + abstain only, against votes don't count toward quorum)
const simulatedQuorumVotes = simulatedForVotes + simulatedAbstainVotes;
// Calculate simulated quorum votes. Standard governors count for + abstain;
// Tornado counts for + against (see TORNClient.calculateQuorum).
const simulatedQuorumVotes = isTorn
? simulatedForVotes + simulatedAgainstVotes
: simulatedForVotes + simulatedAbstainVotes;

const userReadableQuorumVotes = formatNumberUserReadable(
Number(formatUnits(simulatedQuorumVotes || BigInt(0), decimals)),
Expand Down Expand Up @@ -133,7 +136,10 @@ export const VotingModal = ({
return true;
});

return addresses.length > 0 ? addresses : [address];
// Solo voter (no delegators): send an empty `from`. The TORN governor casts
// the voter's own balance separately and reverts on self-delegation, so the
// voter's own address must never appear in this list.
return addresses;
}, [address, isTorn, tornDelegators]);

const { minVotingPower } = useRelayerConfig(daoId);
Expand Down
33 changes: 29 additions & 4 deletions apps/gateful/src/auth/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ const AUTH: AuthContext = {

describe("normalizeRoute", () => {
it.each([
["/ens/proposals/0x123", "/{dao}/proposals"],
["/ens/account-balances/0xabc", "/{dao}/account-balances"],
["/ens", "/{dao}"],
// DAO routes collapse every sub-resource to a single label so a
// caller-controlled segment can't blow up cardinality.
["/ens/proposals/0x123", "/{dao}/*"],
["/ens/account-balances/0xabc", "/{dao}/*"],
["/ens/random-b3f8-uuid", "/{dao}/*"],
["/ens", "/{dao}/*"],
["/", "/"],
// Any non-DAO first segment buckets to /unknown to bound cardinality.
["/daos", "/unknown"],
Expand Down Expand Up @@ -54,7 +57,7 @@ describe("usageMiddleware", () => {

expect(add).toHaveBeenCalledWith(1, {
tenant: AUTH.tenant,
route: "/{dao}/proposals",
route: "/{dao}/*",
});
});

Expand All @@ -65,4 +68,26 @@ describe("usageMiddleware", () => {

expect(add).not.toHaveBeenCalled();
});

it("counts requests even when the downstream handler throws", async () => {
const add = vi.spyOn(tenantRequestTotal, "add");
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("auth", AUTH);
await next();
});
app.use("*", usageMiddleware(DAO_APIS));
app.get("/ens/proposals/:id", () => {
throw new Error("downstream 5xx");
});

await Promise.resolve(app.request("/ens/proposals/0x123")).catch(
() => undefined,
);

expect(add).toHaveBeenCalledWith(1, {
tenant: AUTH.tenant,
route: "/{dao}/*",
});
});
});
38 changes: 22 additions & 16 deletions apps/gateful/src/auth/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@ import { tenantRequestTotal } from "../metrics.js";

/**
* Normalizes a request path to a bounded-cardinality route label:
* - DAO routes keep the resource segment with the DAO templated out:
* `/ens/proposals/0x123` → `/{dao}/proposals`
* - Any non-DAO first segment buckets to `/unknown`. Mirrors the cache
* middleware: clients can send arbitrary paths (including ones that would
* later 404), so passing them through verbatim would let a single caller
* create unbounded route labels. Bucketing keeps the Prometheus label set
* (tenant × route) bounded.
* - DAO routes collapse every sub-resource to `/{dao}/*`:
* `/ens/proposals/0x123` → `/{dao}/*`. The resource segment is
* caller-controlled (any path is accepted at the gateway and only 404s at
* the DAO backend), so keeping it verbatim would let a single caller create
* unbounded route labels. Mirrors the cache middleware's `/{dao}/*` label.
* - Any non-DAO first segment buckets to `/unknown`.
* Bucketing keeps the Prometheus label set (tenant × route) bounded.
*/
export function normalizeRoute(
path: string,
daoApis: Map<string, string>,
): string {
const [, first, second] = path.split("/");
const [, first] = path.split("/");
if (!first) return "/";
if (daoApis.has(first.toLowerCase())) {
return second ? `/{dao}/${second}` : "/{dao}";
return "/{dao}/*";
}
return "/unknown";
}
Expand All @@ -32,12 +32,18 @@ export function normalizeRoute(
*/
export function usageMiddleware(daoApis: Map<string, string>) {
return async (c: Context, next: Next) => {
await next();
const auth = c.get("auth");
if (!auth) return;
tenantRequestTotal.add(1, {
tenant: auth.tenant,
route: normalizeRoute(c.req.path, daoApis),
});
// Record in a finally so failed requests (downstream 5xx surfaced as a
// thrown error, or a later middleware returning 429) are still counted.
try {
await next();
} finally {
const auth = c.get("auth");
if (auth) {
tenantRequestTotal.add(1, {
tenant: auth.tenant,
route: normalizeRoute(c.req.path, daoApis),
});
}
}
};
}
8 changes: 8 additions & 0 deletions apps/gateful/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export const envSchema = z
.transform((url) => url.replace(/\/+$/, ""))
.optional(),
TOKEN_SERVICE_API_KEY: z.string().optional(),
// Explicit opt-in to run without per-tenant auth (local dev only). Without
// this flag, a missing TOKEN_SERVICE_URL is a hard startup error rather than
// silently serving every DAO/relayer route publicly (fail closed).
GATEFUL_AUTH_DISABLED: z
.string()
.optional()
.transform((v) => v === "true"),
// Shared bearer protecting the public `/metrics` endpoint from scraping by
// anyone but our Prometheus instance. Distinct from per-tenant Authful auth:
// the scraper is infrastructure, not a tenant, so it must not consume a
Expand Down Expand Up @@ -62,6 +69,7 @@ export const config = {
tokenService: env.TOKEN_SERVICE_URL
? { url: env.TOKEN_SERVICE_URL, apiKey: env.TOKEN_SERVICE_API_KEY! }
: undefined,
authDisabled: env.GATEFUL_AUTH_DISABLED,
metricsToken: env.GATEFUL_METRICS_TOKEN,
redisUrl: env.REDIS_URL,
commitSha: env.RAILWAY_GIT_COMMIT_SHA,
Expand Down
23 changes: 23 additions & 0 deletions apps/gateful/src/health/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,29 @@ describe("gateway health route", () => {
expect(registry.get("ens").state).toBe("CLOSED");
});

it("probes the configured token service and reports it degraded when down", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response("fail", { status: 500 })),
);

const { app } = appWithHealth({
daoApis: new Map(),
daoRelayers: new Map(),
tokenServiceUrl: "http://authful.example",
});

const res = await app.request("/health");
const body = await readHealthResponse(res);

expect(res.status).toBe(503);
expect(body.upstreams.authful).toMatchObject({
status: "down",
circuit: "CLOSED",
error: "authful /health returned 500",
});
});

it("returns ok when no upstreams are configured", async () => {
const fetch = okFetch();
vi.stubGlobal("fetch", fetch);
Expand Down
14 changes: 13 additions & 1 deletion apps/gateful/src/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const route = createRoute({
path: "/health",
summary: "Gateway health and upstream dependency states",
description:
"Returns 200 only when every configured DAO API, relayer, and address enrichment upstream responds to /health.",
"Returns 200 only when every configured DAO API, relayer, address enrichment, and token service upstream responds to /health.",
tags: ["system"],
responses: {
200: {
Expand All @@ -51,6 +51,7 @@ type HealthOptions = {
daoApis: Map<string, string>;
daoRelayers: Map<string, string>;
addressEnrichmentUrl?: string;
tokenServiceUrl?: string;
commitSha?: string;
};

Expand Down Expand Up @@ -97,6 +98,17 @@ function buildProbeTargets(opts: HealthOptions): ProbeTarget[] {
});
}

// Authful is a runtime dependency for validating uncached tokens; report it
// so readiness fails when it is down (the breaker key is inert here — the
// probe never calls breaker.execute).
if (opts.tokenServiceUrl) {
targets.push({
name: "authful",
baseUrl: opts.tokenServiceUrl,
circuitKey: "authful",
});
}

return targets;
}

Expand Down
Loading
Loading