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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Next.js ──/api/* (server)──► NestJS (v1/*) ──► Supabase (Pos
| `wallets` | Wallet linking |
| `notifications` | Resend-based email notifications + HTML templates |
| `internal-trustless` | Relay to Trustless Work + typed escrow read endpoints |
| `verification` | KYC/KYB compliance status, aggregated across providers (single source of truth) |

## Authentication & security

Expand All @@ -127,6 +128,8 @@ The browser must call the Next.js frontend (`/api/...`), which forwards to this
| `GET\|POST\|PATCH /v1/agreements/*` | Bearer JWT | Agreement CRUD, milestones, status, activity |
| `GET\|POST\|PATCH /v1/disputes/*` | Bearer JWT | Dispute lifecycle |
| `GET /v1/users/search` | Bearer JWT | Profile search |
| `GET /v1/verification/user/:id` | Bearer JWT (self/admin) or internal secret | Standardized KYC compliance status for a user. 403 unless the caller is the user, an admin, or an internal service |
| `GET /v1/verification/business/:id` | Bearer JWT (admin) or internal secret | Standardized KYB compliance status for a business. 403 unless the caller is an admin or an internal service |
| `GET\|POST\|DELETE /v1/contacts` | Bearer JWT | Contacts |
| `POST /v1/internal/notifications/*` | Internal secret | Trigger transactional emails |

Expand All @@ -136,7 +139,17 @@ The Trustless Work relay only allows paths under `deployer/`, `escrow/`, and `he

## Database & migrations

SQL migrations live under [`scripts/`](scripts). Apply them to the Supabase project before running the API.
SQL migrations live under [`scripts/`](scripts), numbered in apply order. Apply them to the Supabase project before running the API. Numbers must be unique across open PRs — when two branches would claim the same number, the later one bumps to the next free number (this is why verifications is `004_create_verifications.sql`, clearing `002`/`003` used by the KYB, KYC and retry-queue migrations).

### Verification as a single source of truth (`verifications` table)

`scripts/004_create_verifications.sql` creates `public.verifications` — the standardized, provider-agnostic projection the Verification API (issue #74) reads from. It is intentionally **read/aggregation only**: this module never writes to it.

For a subject to ever report as verified, the identity-provider flows must **upsert their outcome into `verifications`** (one row per subject + provider):

- **KYB** (`src/kyb/`, table `kyb_verifications`) and **KYC** (issue #72 / #80) remain the systems of record for their own provider sessions.
- On a terminal status change (verified / rejected / expired) they must sync a row into `verifications` — keyed `(subject_type, subject_id, provider)` — so downstream consumers (Agreements, Reputation, Enterprise) read one shape regardless of provider.
- Until that write path exists, these endpoints correctly return `unverified`; they are not aggregating the legacy provider tables directly, by design (so #71 / #72 / #75 don't each invent a different status shape).

## Notifications (EventEmitter2)

Expand Down
47 changes: 47 additions & 0 deletions scripts/004_create_verifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- Migration: Create verifications table (KYC/KYB compliance status)
-- Issue: #74 — Create Verification Status & Compliance API
-- NOT YET APPLIED — run this against the Supabase project before using /v1/verification/*
--
-- Single source of truth for compliance data. One subject (a user or a business)
-- may have several rows, one per identity provider (Sumsub, Persona, Veriff,
-- manual review, ...). The Verification API aggregates them into a standardized
-- response, so callers never care which provider produced the data.

CREATE TABLE IF NOT EXISTS public.verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'business')),
subject_id UUID NOT NULL,
provider TEXT,
provider_reference TEXT,
status TEXT NOT NULL DEFAULT 'unverified'
CHECK (status IN ('unverified', 'pending', 'verified', 'expired', 'rejected')),
level TEXT NOT NULL DEFAULT 'none'
CHECK (level IN ('none', 'basic', 'standard', 'advanced')),
verified_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_verifications_subject
ON public.verifications (subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_verifications_status ON public.verifications (status);
CREATE INDEX IF NOT EXISTS idx_verifications_provider ON public.verifications (provider);

-- At most one row per (subject, provider); a subject may still use several providers.
CREATE UNIQUE INDEX IF NOT EXISTS idx_verifications_subject_provider
ON public.verifications (subject_type, subject_id, provider)
WHERE provider IS NOT NULL;

ALTER TABLE public.verifications ENABLE ROW LEVEL SECURITY;

-- The API reads through the service role, which bypasses RLS. This policy only
-- scopes *direct* client access: an individual user may read their own KYC rows.
-- Business (KYB) rows stay service-role-only until org membership exists.
CREATE POLICY "Users can view their own verifications"
ON public.verifications FOR SELECT
USING (subject_type = 'user' AND subject_id = auth.uid());

COMMENT ON TABLE public.verifications IS
'KYC (user) and KYB (business) compliance status, aggregated across identity providers. Single source of truth for verification. See issue #74.';
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ProfilesModule } from './profiles/profiles.module';
import { WalletsModule } from './wallets/wallets.module';
import { EventsModule } from './events/events.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { VerificationModule } from './verification/verification.module';
import { KybModule } from './kyb/kyb.module';

@Module({
Expand All @@ -36,6 +37,7 @@ import { KybModule } from './kyb/kyb.module';
WalletsModule,
EventsModule,
WebhooksModule,
VerificationModule,
KybModule,
],
controllers: [RootController],
Expand Down
35 changes: 35 additions & 0 deletions src/verification/jwt-or-internal-secret.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

/**
* Lets a request through when EITHER:
* - it carries a valid internal service secret (`x-thalos-internal-secret`) —
* a trusted server-to-server consumer such as Agreements or Reputation; the
* request is flagged `req.isInternalService = true`, or
* - it carries a valid app JWT (delegated to {@link JwtAuthGuard}, which
* populates `req.user`).
*
* This only decides *authentication*. Fine-grained authorization (the caller may
* only read a subject they own, or must be an admin) is enforced in
* `VerificationService.assertCanRead`.
*/
@Injectable()
export class JwtOrInternalSecretGuard implements CanActivate {
private readonly jwtGuard = new JwtAuthGuard();

async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request & { isInternalService?: boolean }>();

const expected = process.env.THALOS_INTERNAL_SECRET;
const header = req.headers['x-thalos-internal-secret'];
const value = Array.isArray(header) ? header[0] : header;

if (expected && value && value === expected) {
req.isInternalService = true;
return true;
}

return (await this.jwtGuard.canActivate(context)) as boolean;
}
}
53 changes: 53 additions & 0 deletions src/verification/verification.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Controller, Get, Param, ParseUUIDPipe, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { JwtOrInternalSecretGuard } from './jwt-or-internal-secret.guard';
import { AuthUserCtx } from '../auth/current-user.decorator';
import { VerificationService } from './verification.service';
import { VerificationAccessContext, VerificationStatusResponse } from './verification.types';

@ApiTags('verification')
@ApiBearerAuth('bearer')
@ApiSecurity('thalos-internal')
@UseGuards(JwtOrInternalSecretGuard)
@Controller('verification')
export class VerificationController {
constructor(private readonly verification: VerificationService) {}

@Get('user/:id')
@ApiOperation({
summary: 'KYC status for an individual user',
description:
'Standardized, provider-agnostic compliance status for a user. Always 200 — an unknown or unverified subject returns an `unverified` payload rather than 404. Restricted to the user themselves, an admin, or an internal service; other callers get 403.',
})
@ApiParam({ name: 'id', description: 'User (profile) UUID', format: 'uuid' })
getUser(
@Param('id', new ParseUUIDPipe()) id: string,
@Req() req: Request,
): Promise<VerificationStatusResponse> {
return this.verification.getUserVerification(id, accessContext(req));
}

@Get('business/:id')
@ApiOperation({
summary: 'KYB status for an organization',
description:
'Standardized, provider-agnostic compliance status for a business. Always 200 — an unknown or unverified subject returns an `unverified` payload rather than 404. Restricted to an admin or an internal service; other callers get 403.',
})
@ApiParam({ name: 'id', description: 'Business (organization) UUID', format: 'uuid' })
getBusiness(
@Param('id', new ParseUUIDPipe()) id: string,
@Req() req: Request,
): Promise<VerificationStatusResponse> {
return this.verification.getBusinessVerification(id, accessContext(req));
}
}

/** Builds the access context the guard left on the request (JWT user and/or internal-service flag). */
function accessContext(req: Request): VerificationAccessContext {
const typed = req as Request & { user?: AuthUserCtx; isInternalService?: boolean };
return {
callerUserId: typed.user?.userId,
isInternalService: typed.isInternalService === true,
};
}
12 changes: 12 additions & 0 deletions src/verification/verification.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SupabaseModule } from '../supabase/supabase.module';
import { VerificationController } from './verification.controller';
import { VerificationService } from './verification.service';

@Module({
imports: [SupabaseModule],
controllers: [VerificationController],
providers: [VerificationService],
exports: [VerificationService],
})
export class VerificationModule {}
Loading
Loading