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
18 changes: 18 additions & 0 deletions src/modules/admin/admin-users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import { adminUsersService } from "./admin-users.service.js";
import type { ListUsersQuery } from "./admin.types.js";
import { credentialService } from "../credentials/credential.service.js";

export class AdminUsersController {
/**
Expand Down Expand Up @@ -56,6 +57,23 @@ export class AdminUsersController {
data: activities,
});
}

/**
* GET /api/v1/admin/users/:id/credentials
* All credentials for a user with live on-chain verification (#410).
*/
async getCredentials(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const credentials = await credentialService.getAdminUserCredentials(id);

reply.send({
success: true,
data: credentials,
});
}
}

export const adminUsersController = new AdminUsersController();
20 changes: 20 additions & 0 deletions src/modules/admin/admin-users.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,24 @@ export async function adminUsersRoutes(app: FastifyInstance): Promise<void> {
},
(request, reply) => adminUsersController.getActivity(request, reply),
);

app.get<{ Params: { id: string } }>(
"/:id/credentials",
{
schema: {
description:
"Get all credentials for a user, each verified against Stellar Horizon (admin only, cached 30s, #410)",
tags: ["admin", "users"],
security: [{ bearerAuth: [] }],
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", format: "uuid" },
},
},
} as FastifySchema,
},
(request, reply) => adminUsersController.getCredentials(request, reply),
);
}
9 changes: 9 additions & 0 deletions src/modules/admin/admin.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,12 @@ export interface AdminUserSummary {
// were cleared".
deletedAt: Date | null;
}

// ─── Admin user credentials listing (#410) ─────────────────────────────────

// Param shape shared with /:id/ban and /:id/activity — kept local (not
// zod-validated) to mirror the existing admin-users routes, which validate
// only where a body/query exists.
export interface UserIdParams {
id: string;
}
76 changes: 76 additions & 0 deletions src/modules/credentials/credential.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { withLock } from "../../utils/lock.js";
import { invokeContract } from "../../stellar/transactions.js";
import { createMintAuthorization } from "../../stellar/signatures.js";
import { stellarClient } from "../../stellar/client.js";
import { config } from "../../config/index.js";
import { logger } from "../../utils/logger.js";
import crypto from "node:crypto";
Expand All @@ -25,6 +26,8 @@ import type {
BatchMintResultItem,
CredentialListItem,
MintResult,
AdminUserCredentialItem,
AdminCredentialVerification,
} from "./credential.types.js";
import { auditLog } from "../../audit/index.js";
import {
Expand Down Expand Up @@ -274,6 +277,79 @@ export class CredentialService {

return rows;
}

/**
* All credentials for a user, joined with course titles and each mint
* transaction verified against Stellar Horizon so admins see the live
* on-chain state instead of trusting the stored hash (#410). Cached for
* 30s — the per-credential Horizon calls make uncached loads expensive,
* while 30s matches the read-frequency of a support/verification flow
* without going meaningfully stale.
*/
async getAdminUserCredentials(userId: string): Promise<AdminUserCredentialItem[]> {
const namespace = "credentials";
const cacheKeyString = cacheKey(namespace, "admin-list", userId);

const cached = await cacheGet<AdminUserCredentialItem[]>(
namespace,
cacheKeyString,
);
if (cached) return cached;

// Reuse the user-facing list() join shape — same table relationships,
// same column set — but do NOT cache-share a key with it: the admin view
// is a different consumer with a different TTL (30s vs 60s).
const rows = await db
.select({
id: credentials.id,
score: credentials.score,
nftAssetCode: credentials.nftAssetCode,
mintTxHash: credentials.mintTxHash,
revoked: credentials.revoked,
mintedAt: credentials.mintedAt,
courseTitle: courses.title,
})
.from(credentials)
.innerJoin(courses, eq(credentials.courseId, courses.id))
.where(eq(credentials.userId, userId))
.orderBy(desc(credentials.mintedAt));

const items: AdminUserCredentialItem[] = await Promise.all(
rows.map(async (row): Promise<AdminUserCredentialItem> => {
let verification: AdminCredentialVerification;
if (!row.mintTxHash) {
verification = { kind: "none", status: "unknown" };
} else if (row.mintTxHash === "pending_indexer_confirmation") {
// Written by the bad-seq recovery path in mint(); nothing to
// look up on Horizon yet — same convention as reward getTransactions.
verification = { kind: "on_chain", status: "pending", ledger: null, confirmations: null };
} else {
const v = await stellarClient.getHorizonTransaction(row.mintTxHash);
verification = {
kind: "on_chain",
status: v.status,
ledger: v.ledger,
confirmations: v.confirmations,
};
}

return {
id: row.id,
courseTitle: row.courseTitle,
score: row.score,
nftAssetCode: row.nftAssetCode,
mintTxHash: row.mintTxHash,
revoked: row.revoked,
mintedAt: row.mintedAt,
verification,
};
}),
);

await cacheSet(cacheKeyString, items, 30);

return items;
}
}

export const credentialService = new CredentialService();
25 changes: 25 additions & 0 deletions src/modules/credentials/credential.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,28 @@ export interface BatchMintResultItem {
message: string;
};
}

export interface AdminUserCredentialItem {
id: string;
courseTitle: string;
score: number;
nftAssetCode: string | null;
mintTxHash: string | null;
revoked: boolean;
mintedAt: Date;
verification:
| { kind: "none"; status: "not_minted" | "unknown" }
| { kind: "on_chain"; status: "confirmed" | "pending" | "failed"; ledger: number | null; confirmations: number | null };
}

// ─── Admin: user credentials listing (#410) ────────────────────────────────

// Verification state resolved per credential in getAdminUserCredentials:
// "not_minted" for rows with no mint tx yet, otherwise the live Horizon
// verification of the stored mint tx hash. Keep in sync with the union on
// AdminUserCredentialItem above.
export type AdminCredentialVerification =
AdminUserCredentialItem["verification"];

// Body/route schemas for #410 live in admin.types.ts (admin_users params use
// the plain :id param shape shared by the module).
203 changes: 203 additions & 0 deletions tests/unit/admin/admin-user-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("../../../src/config/database.js", () => {
const mockDb = { select: vi.fn() };
return { db: mockDb };
});

vi.mock("../../../src/utils/logger.js", () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));

vi.mock("../../../src/audit/index.js", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("../../../src/cache/index.js", () => ({
cacheGet: vi.fn().mockResolvedValue(null),
cacheSet: vi.fn().mockResolvedValue(undefined),
cacheDel: vi.fn().mockResolvedValue(undefined),
cacheInvalidatePattern: vi.fn().mockResolvedValue(undefined),
cacheKey: (...parts: (string | number)[]) => parts.join(":"),
cacheKeyPattern: (...parts: (string | number)[]) => `${parts.join(":")}:*`,
}));

vi.mock("../../../src/stellar/client.js", () => ({
stellarClient: { getHorizonTransaction: vi.fn() },
}));

vi.mock("../../../src/utils/lock.js", () => ({
withLock: vi.fn(async (_key: string, fn: () => Promise<any>) => fn()),
}));

vi.mock("../../../src/config/index.js", () => ({
config: {},
}));

vi.mock("../../../src/metrics/index.js", () => ({
stellarTxDurationSeconds: { observe: vi.fn() },
credentialsMintedTotal: { inc: vi.fn() },
}));

vi.mock("../../../src/stellar/transactions.js", () => ({
invokeContract: vi.fn(),
}));

vi.mock("../../../src/services/webhook-dispatcher.js", () => ({
dispatchWebhook: vi.fn(),
}));

vi.mock("../../../src/services/retry-queue.js", () => ({
enqueueReward: vi.fn(),
}));

import { db } from "../../../src/config/database.js";
import { stellarClient } from "../../../src/stellar/client.js";
import { credentialService } from "../../../src/modules/credentials/credential.service.js";

const mockDb = vi.mocked(db);
const mockHorizon = vi.mocked(stellarClient.getHorizonTransaction);

function credentialsSelectChain(rows: unknown[]) {
const chain: any = {};
chain.select = vi.fn().mockReturnValue(chain);
chain.from = vi.fn().mockReturnValue(chain);
chain.innerJoin = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
// Terminal call — the awaited value.
chain.orderBy = vi.fn().mockResolvedValue(rows);
return chain;
}

describe("CredentialService.getAdminUserCredentials (#410)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHorizon.mockReset();
});

it("returns rows with on-chain verification resolved per credential", async () => {
const rows = [
{
id: "cred-1",
score: 9,
nftAssetCode: "CLABCD1234",
mintTxHash: "txhash-1",
revoked: false,
mintedAt: new Date("2026-08-20T00:00:00Z"),
courseTitle: "Stellar 101",
},
{
id: "cred-2",
score: 8,
nftAssetCode: null,
mintTxHash: null,
revoked: false,
mintedAt: new Date("2026-08-25T00:00:00Z"),
courseTitle: "Soroban Deep Dive",
},
];
mockDb.select.mockReturnValue(credentialsSelectChain(rows));
mockHorizon.mockResolvedValueOnce({
status: "confirmed",
ledger: 12345,
confirmations: 7,
});

const items = await credentialService.getAdminUserCredentials("user-1");

// One Horizon call for the row with a real hash, none for the null one.
expect(mockHorizon).toHaveBeenCalledTimes(1);
expect(mockHorizon).toHaveBeenCalledWith("txhash-1");

expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
id: "cred-1",
courseTitle: "Stellar 101",
verification: {
kind: "on_chain",
status: "confirmed",
ledger: 12345,
confirmations: 7,
},
});
// A row with no mint tx yet has nothing to verify on-chain — marked unknown.
expect(items[1]).toMatchObject({
id: "cred-2",
verification: { kind: "none", status: "unknown" },
});
});

it("maps the pending_indexer_confirmation placeholder to pending without a Horizon lookup", async () => {
const rows = [
{
id: "cred-p",
score: 7,
nftAssetCode: "CLPEND01",
mintTxHash: "pending_indexer_confirmation",
revoked: false,
mintedAt: new Date("2026-09-01T00:00:00Z"),
courseTitle: "Migrations",
},
];
mockDb.select.mockReturnValue(credentialsSelectChain(rows));

const items = await credentialService.getAdminUserCredentials("user-2");

// The placeholder is not a real hash — nothing to look up on Horizon.
expect(mockHorizon).not.toHaveBeenCalled();
expect(items[0].verification).toEqual({
kind: "on_chain",
status: "pending",
ledger: null,
confirmations: null,
});
});

it("returns the cached payload without touching the database", async () => {
const { cacheGet } = await import("../../../src/cache/index.js");
const mockedCacheGet = vi.mocked(cacheGet);
mockedCacheGet.mockResolvedValueOnce([
{
id: "cached-1",
courseTitle: "Cached Course",
score: 10,
nftAssetCode: "CACHED01",
mintTxHash: "tx-cached",
revoked: false,
mintedAt: new Date(),
verification: { kind: "on_chain", status: "confirmed", ledger: 1, confirmations: 2 },
},
] as any);

const items = await credentialService.getAdminUserCredentials("user-3");

expect(mockDb.select).not.toHaveBeenCalled();
expect(mockHorizon).not.toHaveBeenCalled();
expect(items).toHaveLength(1);
expect(items[0].courseTitle).toBe("Cached Course");

mockedCacheGet.mockReset();
});

it("caches the result for 30s", async () => {
const { cacheSet } = await import("../../../src/cache/index.js");
const rows = [
{
id: "cred-4",
score: 6,
nftAssetCode: null,
mintTxHash: null,
revoked: true,
mintedAt: new Date(),
courseTitle: "Revoked Course",
},
];
mockDb.select.mockReturnValue(credentialsSelectChain(rows));

await credentialService.getAdminUserCredentials("user-4");

const call = vi.mocked(cacheSet).mock.calls.at(-1);
// cacheSet(key, value, ttlSeconds) — the TTL is the third argument.
expect(call?.[2]).toBe(30);
});
});
Loading