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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@moonlight-protocol/council-platform",
"version": "0.6.4",
"version": "0.6.5",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
20 changes: 12 additions & 8 deletions src/core/service/auth/council-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Keypair } from "stellar-sdk";
import { Buffer } from "buffer";
import type { Logger } from "@/utils/logger/index.ts";
import { withSpan } from "@/core/tracing.ts";
import * as E from "@/core/service/auth/error.ts";
import { PlatformError } from "@/error/index.ts";

const MAX_PENDING_CHALLENGES = 1000;
let challengeTtlMs = 5 * 60 * 1000;
Expand All @@ -28,7 +30,7 @@ export function createCouncilChallenge(

cleanupExpiredChallenges(deps);
if (pendingChallenges.size >= MAX_PENDING_CHALLENGES) {
throw new Error("Too many pending challenges. Try again later.");
throw new E.TOO_MANY_CHALLENGES();
}
const nonceBytes = crypto.getRandomValues(new Uint8Array(32));
const nonce = btoa(String.fromCharCode(...nonceBytes));
Expand Down Expand Up @@ -57,16 +59,16 @@ export function verifyCouncilChallenge(

const challenge = pendingChallenges.get(nonce);
if (!challenge) {
throw new Error("Challenge not found or expired");
throw new E.CHALLENGE_NOT_FOUND();
}

if (Date.now() - challenge.createdAt > challengeTtlMs) {
pendingChallenges.delete(nonce);
throw new Error("Challenge expired");
throw new E.CHALLENGE_EXPIRED();
}

if (challenge.publicKey !== publicKey) {
throw new Error("Public key mismatch");
throw new E.PUBLIC_KEY_MISMATCH();
}

// Don't consume nonce yet — only delete after successful verification
Expand Down Expand Up @@ -109,15 +111,17 @@ export function verifyCouncilChallenge(
// Raw format
const rawNonce = Buffer.from(nonce, "base64");
if (!keypair.verify(rawNonce, sigBuffer)) {
throw new Error("Invalid signature");
throw new E.INVALID_SIGNATURE();
}
span.addEvent("signature_verified_raw");
}
}
} catch (e) {
throw e instanceof Error && e.message === "Invalid signature"
? e
: new Error("Invalid signature");
// Already-structured failures bubble as-is; any raw verify() error
// (malformed key/signature bytes) is wrapped as INVALID_SIGNATURE with
// the original preserved as the cause.
if (PlatformError.is(e)) throw e;
throw new E.INVALID_SIGNATURE(e);
}

// Signature verified — consume the nonce (one-time use)
Expand Down
99 changes: 99 additions & 0 deletions src/core/service/auth/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { PlatformError } from "@/error/index.ts";

/**
* Council operator authentication errors (challenge issuance + signature
* verification). Each carries a stable `code` the console maps to operator copy,
* and a redacted `api` projection — the internal reason never leaks verbatim.
*/
export enum COUNCIL_AUTH_ERROR_CODES {
TOO_MANY_CHALLENGES = "COUNCIL_AUTH_001",
CHALLENGE_NOT_FOUND = "COUNCIL_AUTH_002",
CHALLENGE_EXPIRED = "COUNCIL_AUTH_003",
PUBLIC_KEY_MISMATCH = "COUNCIL_AUTH_004",
INVALID_SIGNATURE = "COUNCIL_AUTH_005",
}

const source = "@service/auth";

export class TOO_MANY_CHALLENGES extends PlatformError {
constructor() {
super({
source,
code: COUNCIL_AUTH_ERROR_CODES.TOO_MANY_CHALLENGES,
message: "Too many pending challenges",
details:
"The pending-challenge buffer is full; requests are rate limited.",
api: {
status: 429,
message: "Too many pending sign-in attempts",
details: "Please wait a moment and try signing in again.",
},
});
}
}

export class CHALLENGE_NOT_FOUND extends PlatformError {
constructor() {
super({
source,
code: COUNCIL_AUTH_ERROR_CODES.CHALLENGE_NOT_FOUND,
message: "Challenge not found or expired",
details: "No pending challenge matched the supplied nonce.",
api: {
status: 401,
message: "Sign-in challenge not found or expired",
details: "Your sign-in request has expired. Please start again.",
},
});
}
}

export class CHALLENGE_EXPIRED extends PlatformError {
constructor() {
super({
source,
code: COUNCIL_AUTH_ERROR_CODES.CHALLENGE_EXPIRED,
message: "Challenge expired",
details: "The challenge exceeded its time-to-live before verification.",
api: {
status: 401,
message: "Sign-in challenge expired",
details: "Your sign-in request took too long. Please start again.",
},
});
}
}

export class PUBLIC_KEY_MISMATCH extends PlatformError {
constructor() {
super({
source,
code: COUNCIL_AUTH_ERROR_CODES.PUBLIC_KEY_MISMATCH,
message: "Public key mismatch",
details: "The verifying public key did not match the challenge subject.",
api: {
status: 401,
message: "Sign-in key mismatch",
details:
"The wallet used to sign did not match the one that started sign-in.",
},
});
}
}

export class INVALID_SIGNATURE extends PlatformError {
constructor(error?: Error | unknown) {
super({
source,
code: COUNCIL_AUTH_ERROR_CODES.INVALID_SIGNATURE,
message: "Invalid signature",
details: "The supplied signature failed verification for all formats.",
baseError: error,
api: {
status: 401,
message: "Invalid signature",
details: "The signature could not be verified. Please try again.",
},
});
}
}
3 changes: 2 additions & 1 deletion src/core/service/channel/channel-state.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NETWORK_RPC_SERVER } from "@/config/env.ts";
import { withSpan } from "@/core/tracing.ts";
import type { Logger } from "@/utils/logger/index.ts";
import * as E from "@/core/service/channel/error.ts";

export interface ChannelOnChainState {
totalDeposited: bigint | null;
Expand Down Expand Up @@ -41,7 +42,7 @@ export function queryChannelState(
};
} catch (error) {
log.error(error, "failed to query channel state from RPC");
throw new Error("Failed to query channel on-chain state");
throw new E.CHANNEL_STATE_QUERY_FAILED(error);
}
});
}
27 changes: 27 additions & 0 deletions src/core/service/channel/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { PlatformError } from "@/error/index.ts";

/** Channel service errors (on-chain channel state reads). */
export enum CHANNEL_ERROR_CODES {
STATE_QUERY_FAILED = "CHANNEL_001",
}

const source = "@service/channel";

export class CHANNEL_STATE_QUERY_FAILED extends PlatformError {
constructor(error?: Error | unknown) {
super({
source,
code: CHANNEL_ERROR_CODES.STATE_QUERY_FAILED,
message: "Failed to query channel on-chain state",
details: "The channel Auth contract read did not respond successfully.",
baseError: error,
api: {
// 503 — the network dependency is temporarily unavailable; retry.
status: 503,
message: "Channel network is temporarily unavailable",
details:
"The network could not be reached to read channel state. Please try again shortly.",
},
});
}
}
43 changes: 43 additions & 0 deletions src/core/service/escrow/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { PlatformError } from "@/error/index.ts";

/** Escrow service errors (transparent deposits to non-registered recipients). */
export enum ESCROW_ERROR_CODES {
INVALID_AMOUNT = "ESCROW_001",
RECIPIENT_NOT_ACTIVE = "ESCROW_002",
}

const source = "@service/escrow";

export class INVALID_AMOUNT extends PlatformError {
constructor() {
super({
source,
code: ESCROW_ERROR_CODES.INVALID_AMOUNT,
message: "Amount must be positive",
details: "An escrow was requested with a non-positive amount.",
api: {
status: 400,
message: "Amount must be positive",
details: "The escrow amount must be greater than zero.",
},
});
}
}

export class RECIPIENT_NOT_ACTIVE extends PlatformError {
constructor() {
super({
source,
code: ESCROW_ERROR_CODES.RECIPIENT_NOT_ACTIVE,
message: "Recipient is not registered or not active",
details:
"The escrow recipient has no active custodial registration for the channel.",
api: {
status: 400,
message: "Recipient is not registered or not active",
details:
"The recipient does not have an active account for this channel.",
},
});
}
}
5 changes: 3 additions & 2 deletions src/core/service/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CustodialUserStatus } from "@/persistence/drizzle/entity/custodial-user
import { deriveP256PublicKey } from "@/core/service/custody/key-derivation.service.ts";
import { withSpan } from "@/core/tracing.ts";
import type { Logger } from "@/utils/logger/index.ts";
import * as E from "@/core/service/escrow/error.ts";

const escrowRepo = new CouncilEscrowRepository(drizzleClient);
const userRepo = new CustodialUserRepository(drizzleClient);
Expand Down Expand Up @@ -95,7 +96,7 @@ export function createEscrow(opts: {
span.setAttribute("escrow.asset_code", opts.assetCode);

if (opts.amount <= 0n) {
throw new Error("Amount must be positive");
throw new E.INVALID_AMOUNT();
}

const escrow = await escrowRepo.create({
Expand Down Expand Up @@ -203,7 +204,7 @@ export function releaseEscrowsForRecipient(
channelContractId,
);
if (!user || user.status !== CustodialUserStatus.ACTIVE) {
throw new Error("Recipient is not registered or not active");
throw new E.RECIPIENT_NOT_ACTIVE();
}

let totalReleased = 0n;
Expand Down
6 changes: 6 additions & 0 deletions src/core/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { type Span, SpanStatusCode, trace } from "@opentelemetry/api";

const tracer = trace.getTracer("council-platform");

/** The active W3C trace id, if a span is in scope — for log correlation. */
export function currentTraceId(): string | undefined {
const spanContext = trace.getActiveSpan()?.spanContext();
return spanContext?.traceId;
}

export function withSpan<T>(
name: string,
fn: (span: Span) => Promise<T> | T,
Expand Down
8 changes: 7 additions & 1 deletion src/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ export class PlatformError<M = undefined | unknown> extends Error {
readonly api?: APIDetails;

constructor(e: PlatformErrorShape<M>) {
super(e.message);
// Preserve the wrapped original as the native `cause` so the full chain
// flows into logs/OTel (see logger `flattenCauses`), while the redacted
// `api` projection is all that reaches the client.
super(
e.message,
e.baseError !== undefined ? { cause: e.baseError } : undefined,
);
this.name = "ML Platform Error: " + e.code;
this.code = e.code;
this.source = e.source;
Expand Down
3 changes: 3 additions & 0 deletions src/http/default-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const errorStatusSchema = z.union([
z.literal(Status.InternalServerError),
z.literal(Status.TooManyRequests),
z.literal(Status.Conflict),
// 503 — a dependency (e.g. the Soroban RPC / channel on-chain read) is
// temporarily unreachable and the client should retry.
z.literal(Status.ServiceUnavailable),
]);

export const errorResponseSchema = z.object({
Expand Down
20 changes: 9 additions & 11 deletions src/http/middleware/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ import type { JwtPayload } from "@/core/service/auth/generate-jwt.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { isDefined } from "@/utils/type-guards/is-defined.ts";
import * as E from "@/http/middleware/auth/error.ts";
import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts";
import { PlatformError } from "@/error/index.ts";

export function jwtMiddleware(
deps: { log: Logger },
): (ctx: Context, next: () => Promise<unknown>) => Promise<void> {
// deps kept for signature parity with the other middleware factories.
void deps;
return async (ctx, next) => {
const authorization = ctx.request.headers.get("authorization");
if (!isDefined(authorization)) {
await PIPE_APIError(ctx, deps).run(new E.MISSING_AUTHORIZATION_HEADER());
return;
throw new E.MISSING_AUTHORIZATION_HEADER();
}

const parts = authorization.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
await PIPE_APIError(ctx, deps).run(new E.INVALID_AUTHORIZATION_HEADER());
return;
throw new E.INVALID_AUTHORIZATION_HEADER();
}
const token = parts[1];

Expand All @@ -30,16 +30,14 @@ export function jwtMiddleware(

const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp === "number" && now > payload.exp) {
await PIPE_APIError(ctx, deps).run(new E.EXPIRED_TOKEN());
return;
throw new E.EXPIRED_TOKEN();
}

ctx.state.session = payload;
} catch (error) {
await PIPE_APIError(ctx, deps).run(
new E.JWT_VERIFICATION_FAILED(error),
);
return;
// Preserve already-structured auth errors; wrap the raw verify() failure.
if (PlatformError.is(error)) throw error;
throw new E.JWT_VERIFICATION_FAILED(error);
}
await next();
};
Expand Down
Loading
Loading