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/provider-platform",
"version": "0.7.0",
"version": "0.7.1",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
70 changes: 43 additions & 27 deletions src/core/service/auth/challenge/store/attach-entity-status.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,71 @@
import { decode as decodeJwt } from "@zaubrik/djwt";
import { type MetadataHelper, ProcessEngine } from "@fifo/convee";
import { AccountRepository } from "@/persistence/drizzle/repository/account.repository.ts";
import { EntityRepository } from "@/persistence/drizzle/repository/entity.repository.ts";
import { PpRepository } from "@/persistence/drizzle/repository/pp.repository.ts";
import { PpEntityApprovalRepository } from "@/persistence/drizzle/repository/pp-entity-approval.repository.ts";
import { drizzleClient } from "@/persistence/drizzle/config.ts";
import { EntityStatus } from "@/persistence/drizzle/entity/entity.entity.ts";
import type {
ContextWithJWT,
ContextWithJWTAndPpPublicKey,
ContextWithJWTAndStatus,
} from "@/core/service/auth/challenge/types.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { withSpan } from "@/core/tracing.ts";

const accountRepository = new AccountRepository(drizzleClient);
const entityRepository = new EntityRepository(drizzleClient);
const ppRepository = new PpRepository(drizzleClient);
const ppEntityApprovalRepository = new PpEntityApprovalRepository(
drizzleClient,
);

// SEP-10 verify reports the submitter's entity status alongside the JWT.
// Unregistered submitters (no account row yet) report UNVERIFIED so the
// wallet can route them to the KYC submission step instead of letting them
// blind-submit a bundle that the bundle gate (BND_011) would reject.
// SEP-10 verify is PP-aware: the wallet posts `ppPublicKey` alongside the
// signed challenge, and this step reports the submitter's per-PP entity
// status + the operator-configured KYC submission URL for that PP. A wallet
// APPROVED on PP-A must not appear APPROVED on PP-B.
export const P_AttachEntityStatus = (deps: { log: Logger }) =>
ProcessEngine.create(
(
input: ContextWithJWT,
input: ContextWithJWTAndPpPublicKey,
_metadataHelper?: MetadataHelper,
): Promise<ContextWithJWTAndStatus> => {
return withSpan("P_AttachEntityStatus", async (span) => {
const log = deps.log.scope("P_AttachEntityStatus");
// We just minted this JWT in P_GenerateChallengeJWT; decode-without-
// verify is sufficient to read its own `sub` (the client account).

const { ppPublicKey } = input;
const [, payload] = decodeJwt(input.jwt);
const clientAccount = (payload as { sub?: string }).sub;
if (!clientAccount) {
log.event("no sub on JWT; reporting UNVERIFIED");
return { ...input, entityStatus: EntityStatus.UNVERIFIED };
}
span.addEvent("looking_up_entity", {
"client.account": clientAccount,
const accountPubkey = (payload as { sub?: string }).sub;

const make = (
entityStatus: EntityStatus,
kycSubmissionUrl: string | null,
): ContextWithJWTAndStatus => ({
ctx: input.ctx,
jwt: input.jwt,
entityStatus,
kycSubmissionUrl,
});

const account = await accountRepository.findById(clientAccount);
if (!account) {
log.event("no account record for submitter; reporting UNVERIFIED");
span.addEvent("no_account");
return { ...input, entityStatus: EntityStatus.UNVERIFIED };
if (!accountPubkey) {
log.event("no sub on JWT; reporting UNVERIFIED + null URL");
return make(EntityStatus.UNVERIFIED, null);
}
const entity = await entityRepository.findById(account.entityId);
const entityStatus = entity?.status ?? EntityStatus.UNVERIFIED;

span.addEvent("loading_pp", { "pp.public_key": ppPublicKey });
const pp = await ppRepository.findByPublicKey(ppPublicKey);
if (!pp) {
log.event("pp not found; reporting UNVERIFIED + null URL");
return make(EntityStatus.UNVERIFIED, null);
}
const kycSubmissionUrl = pp.kycSubmissionUrl ?? null;

const approval = await ppEntityApprovalRepository.findByPpAndAccount(
ppPublicKey,
accountPubkey,
);
const entityStatus = approval?.status ?? EntityStatus.UNVERIFIED;
span.addEvent("entity_status_attached", {
"entity.status": entityStatus,
"pp.has_kyc_url": String(kycSubmissionUrl !== null),
});
return { ...input, entityStatus };
return make(entityStatus, kycSubmissionUrl);
});
},
{
Expand Down
2 changes: 0 additions & 2 deletions src/core/service/auth/challenge/store/create-challenge-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { EntityRepository } from "@/persistence/drizzle/repository/entity.reposi
import { AccountRepository } from "@/persistence/drizzle/repository/account.repository.ts";
import {
ChallengeStatus,
EntityStatus,
type NewAccount,
type NewChallenge,
type NewEntity,
Expand Down Expand Up @@ -44,7 +43,6 @@ export const P_CreateChallengeDB = (deps: { log: Logger }) =>
log.event("creating new entity and account");
entity = await entityRepository.create({
id: crypto.randomUUID(),
status: EntityStatus.UNVERIFIED,
} as NewEntity);

account = await accountRepository.create({
Expand Down
9 changes: 5 additions & 4 deletions src/core/service/auth/challenge/store/update-challenge-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts";
import { ChallengeRepository } from "@/persistence/drizzle/repository/challenge.repository.ts";
import { ChallengeStatus } from "@/persistence/drizzle/entity/challenge.entity.ts";
import type {
ContextWithJWT,
ContextWithJWTAndPpPublicKey,
PostChallengeWithJWT,
} from "@/core/service/auth/challenge/types.ts";
import * as E from "@/core/service/auth/challenge/store/error.ts";
Expand All @@ -18,12 +18,12 @@ const challengeRepository = new ChallengeRepository(drizzleClient);

export const P_UpdateChallengeDB = (deps: { log: Logger }) =>
ProcessEngine.create(
(input: PostChallengeWithJWT): Promise<ContextWithJWT> => {
(input: PostChallengeWithJWT): Promise<ContextWithJWTAndPpPublicKey> => {
return withSpan("P_UpdateChallengeDB", async (span) => {
const log = deps.log.scope("P_UpdateChallengeDB");
log.info("P_UpdateChallengeDB");

const { signedChallenge } = input.body;
const { signedChallenge, ppPublicKey } = input.body;
const tx = new Transaction(
signedChallenge,
NETWORK_CONFIG.networkPassphrase,
Expand Down Expand Up @@ -51,7 +51,8 @@ export const P_UpdateChallengeDB = (deps: { log: Logger }) =>

return {
ctx: input.ctx,
jwt: input.jwt!,
jwt: input.jwt,
ppPublicKey,
};
});
},
Expand Down
10 changes: 7 additions & 3 deletions src/core/service/auth/challenge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@ import type { EntityStatus } from "@/persistence/drizzle/entity/entity.entity.ts
export type PostChallengeInput = PostEndpointInput<typeof postRequestSchema>;

export type PostChallengeWithJWT = PostChallengeInput & { jwt: string };
export type PostChallengeWithEntityStatus = PostChallengeWithJWT & {
entityStatus: EntityStatus;
};
export type ContextWithJWT = {
ctx: Context;
jwt: string;
};
// P_UpdateChallengeDB narrows from PostChallengeWithJWT to drop the full
// request body, forwarding only the single field (`ppPublicKey`) that
// P_AttachEntityStatus needs. Principle of least information.
export type ContextWithJWTAndPpPublicKey = ContextWithJWT & {
ppPublicKey: string;
};
export type ContextWithJWTAndStatus = ContextWithJWT & {
entityStatus: EntityStatus;
kycSubmissionUrl: string | null;
};

export type ChallengeData = {
Expand Down
29 changes: 20 additions & 9 deletions src/core/service/bundle/add-bundle.process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ import {
SessionRepository,
UtxoRepository,
} from "@/persistence/drizzle/repository/index.ts";
import { PpEntityApprovalRepository } from "@/persistence/drizzle/repository/pp-entity-approval.repository.ts";
import { EntityStatus } from "@/persistence/drizzle/entity/index.ts";
import { withSpan } from "@/core/tracing.ts";
import type { Logger } from "@/utils/logger/index.ts";

const sessionRepository = new SessionRepository(drizzleClient);
const accountRepository = new AccountRepository(drizzleClient);
const entityRepository = new EntityRepository(drizzleClient);
const ppApprovalRepository = new PpEntityApprovalRepository(drizzleClient);
const utxoRepository = new UtxoRepository(drizzleClient);
const operationsBundleRepository = new OperationsBundleRepository(
drizzleClient,
Expand Down Expand Up @@ -342,21 +344,30 @@ export const P_AddOperationsBundle = (deps: { log: Logger }) =>

span.addEvent("validating_entity_approval", {
"account.id": userSession.accountId,
"pp.public_key": ppPublicKey,
});
log.event("validating entity approval");
log.event("validating per-PP entity approval");
const submitterAccount = await accountRepository.findById(
userSession.accountId,
);
const submitterEntity = submitterAccount
? await entityRepository.findById(submitterAccount.entityId)
: null;
if (
!submitterEntity ||
submitterEntity.status !== EntityStatus.APPROVED
) {
log.event("submitter entity not approved");
if (!submitterAccount) {
log.event("submitter has no account; reject");
throw new E.SUBMITTER_NOT_APPROVED(userSession.accountId);
}
const approval = await ppApprovalRepository.findByPpAndAccount(
ppPublicKey,
submitterAccount.id,
);
if (!approval || approval.status !== EntityStatus.APPROVED) {
log.event("submitter entity not approved for this PP");
throw new E.SUBMITTER_NOT_APPROVED(userSession.accountId);
}
// Identity (name / jurisdictions) still lives on the global entity
// record; the per-PP approval above is the gate, but the bundle entry
// wants the submitter's identity for downstream audit/labels.
const submitterEntity = await entityRepository.findById(
submitterAccount.entityId,
);

span.addEvent("generating_bundle_id");
log.event("generating bundle ID");
Expand Down
6 changes: 5 additions & 1 deletion src/http/v1/dashboard/pp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function handleRegisterPp(
log.info("registerPp");
try {
const body = await ctx.request.body.json();
const { secretKey, derivationIndex, label } = body;
const { secretKey, derivationIndex, label, kycSubmissionUrl } = body;

if (!secretKey || typeof secretKey !== "string") {
ctx.response.status = Status.BadRequest;
Expand Down Expand Up @@ -78,6 +78,10 @@ export function handleRegisterPp(
ownerPublicKey,
isActive: true,
label: label?.trim() ?? null,
kycSubmissionUrl: typeof kycSubmissionUrl === "string" &&
kycSubmissionUrl.trim().length > 0
? kycSubmissionUrl.trim()
: null,
createdAt: new Date(),
updatedAt: new Date(),
});
Expand Down
Loading
Loading