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
174 changes: 164 additions & 10 deletions api-gateway/src/api/service/account.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { IAuthUser, NotificationHelper, PinoLogger } from '@guardian/common';
import { Permissions, PolicyStatus, SchemaEntity, UserRole } from '@guardian/interfaces';
import { IAuthUser, NotificationHelper, PinoLogger, RunFunctionAsync } from '@guardian/common';
import { Permissions, PolicyStatus, SchemaEntity, TaskAction, UserRole } from '@guardian/interfaces';
import { ClientProxy } from '@nestjs/microservices';
import { Body, Controller, Get, Headers, HttpCode, HttpException, HttpStatus, Inject, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiConflictResponse, ApiCreatedResponse, ApiInternalServerErrorResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, getSchemaPath } from '@nestjs/swagger';
import { ApiBearerAuth, ApiAcceptedResponse, ApiBody, ApiConflictResponse, ApiCreatedResponse, ApiExtraModels, ApiInternalServerErrorResponse, ApiOkResponse, ApiOperation, ApiTags, ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, getSchemaPath } from '@nestjs/swagger';
import {
AccessTokenRequestDTO,
AccessTokenResponseDTO,
Expand All @@ -16,7 +16,7 @@ import {
Examples,
InternalServerErrorDTO,
LoginUserDTO,
RegisterUserDTO,
OnboardingDTO, RegisterUserDTO, TaskDTO,
StandardRegistryAccountDTO,
UnauthorizedErrorDTO,
UnprocessableEntityErrorDTO,
Expand All @@ -31,7 +31,7 @@ import {
OTPStatusResponseDTO
} from '#middlewares';
import { Auth, AuthUser, checkPermission } from '#auth';
import { EntityOwner, Guardians, InternalException, PolicyEngine, UseCache, Users } from '#helpers';
import { EntityOwner, Guardians, InternalException, PolicyEngine, ServiceError, TaskManager, UseCache, Users } from '#helpers';
import { PolicyListResponse } from '../../entities/policy';
import { StandardRegistryAccountResponse } from '../../entities/account';
import { ApplicationEnvironment } from '../../environment.js';
Expand Down Expand Up @@ -167,11 +167,7 @@ export class AccountApi {
if (!parentUser) {
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
}
try {
await checkPermission(UserRole.STANDARD_REGISTRY)(parentUser);
} catch (error) {
await InternalException(error, this.logger, parentUser?.id);
}
await checkPermission(UserRole.STANDARD_REGISTRY)(parentUser);
}
try {
const { role, username, password } = body;
Expand All @@ -191,6 +187,164 @@ export class AccountApi {
}
}

/**
* Registers and fully onboards a new user in a single async call.
*
* @param body.username - Username for the new account
* @param body.password - Password
* @param body.password_confirmation - Must match password
* @param body.role - UserRole.USER or UserRole.STANDARD_REGISTRY
* @param body.hederaAccountId - Optional. Auto-generated from operator if omitted
* @param body.hederaAccountKey - Required when hederaAccountId is provided
* @param body.parent - Optional. Standard Registry username/DID (USER role only)
* @param body.vcDocument - Optional. VC subject to publish during setup
* @param body.didDocument - Optional. Custom DID document; auto-generated if omitted
* @param body.didKeys - Optional. Keys for the custom DID document
* @param body.useFireblocksSigning - Optional. Use Fireblocks instead of local key
* @param body.fireblocksConfig - Optional. Fireblocks configuration
*
* @returns TaskDTO — poll GET /tasks/:taskId for result
*/
@Post('/push/onboard')
@ApiOperation({
summary: 'Registers and fully onboards a new user account.',
description:
'Creates a user account, ' +
'Hedera account, DID, and cryptographic keys on behalf of the user. ' +
'If hederaAccountId / hederaAccountKey are omitted the platform generates them. ',
})
@ApiBody({
description: 'Registration and optional Hedera / DID credentials.',
type: OnboardingDTO,
})
@ApiAcceptedResponse({
description: 'Task created — poll for completion.',
type: TaskDTO,
})
@ApiInternalServerErrorResponse({
description: 'Internal server error.',
type: InternalServerErrorDTO,
})
@ApiExtraModels(OnboardingDTO, TaskDTO, InternalServerErrorDTO)
@HttpCode(HttpStatus.ACCEPTED)
async registerAndOnboard(
@Body() body: OnboardingDTO,
@Req() req: any,
): Promise<TaskDTO> {
const users = new Users();
let parentUser: IAuthUser | null = null;

const HEDERA_ACCOUNT_ID_REGEX = /^\d+\.\d+\.\d+$/;
const HEDERA_ACCOUNT_KEY_REGEX =
/^(?:302e020100300506032b657004220420[0-9a-fA-F]{64}|[0-9a-fA-F]{64})$/;

if (!ApplicationEnvironment.demoMode) {
const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1];
try {
parentUser = await users.getUserByToken(token) as IAuthUser;
} catch (_) {
parentUser = null;
}
if (!parentUser) {
throw new HttpException('UNAUTHORIZED', HttpStatus.UNAUTHORIZED);
}
await checkPermission(UserRole.STANDARD_REGISTRY)(parentUser);
}

if (body.hederaAccountId || body.hederaAccountKey) {
if (!body.hederaAccountId || !HEDERA_ACCOUNT_ID_REGEX.test(body.hederaAccountId)) {
throw new HttpException(
'Invalid hederaAccountId format. Expected format: 0.0.XXXXX',
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
if (!body.hederaAccountKey || !HEDERA_ACCOUNT_KEY_REGEX.test(body.hederaAccountKey)) {
throw new HttpException(
'Invalid hederaAccountKey format. Expected a valid Hedera private key',
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}

if (body.useFireblocksSigning === true && !body.fireblocksConfig) {
throw new HttpException(
'fireblocksConfig is required when useFireblocksSigning is true',
HttpStatus.UNPROCESSABLE_ENTITY,
);
}

// USER role must have a Standard Registry parent
if (body.role === UserRole.USER) {
if (!body.parent?.trim()) {
throw new HttpException(
'parent (Standard Registry username) is required for USER role accounts',
HttpStatus.BAD_REQUEST,
);
}
const parentSR = await users.getUser(body.parent.trim(), parentUser?.id ?? null);
if (!parentSR) {
throw new HttpException(
`Standard Registry '${body.parent}' not found`,
HttpStatus.BAD_REQUEST,
);
}
if (parentSR.role !== UserRole.STANDARD_REGISTRY) {
throw new HttpException(
`'${body.parent}' is not a Standard Registry`,
HttpStatus.BAD_REQUEST,
);
}
body.parent = parentSR.did;
}

const existingUser = await users.getUser(body.username, parentUser?.id ?? null);
if (existingUser) {
throw new HttpException(
`Username '${body.username}' is already taken`,
HttpStatus.CONFLICT,
);
}

if (body.hederaAccountId) {
const existingHederaUser = await users.getUserByAccount(body.hederaAccountId);
if (existingHederaUser) {
throw new HttpException(
`Hedera account '${body.hederaAccountId}' is already associated with an existing registration`,
HttpStatus.CONFLICT,
);
}
}

const taskManager = new TaskManager();
const task = taskManager.start(TaskAction.ONBOARD_USER, parentUser?.id ?? null);

// After the task completes, transfer ownership to the newly created user
taskManager.registerCallback(task, async (completedTask) => {
if (completedTask.result?.username) {
try {
const users = new Users();
const newUser = await users.getUser(completedTask.result.username, parentUser?.id ?? null);
if (newUser?.id) {
taskManager.transferOwnership(task.taskId, newUser.id);
}
} catch (_) {
// Non-fatal — ownership transfer best-effort
}
}
});

RunFunctionAsync<ServiceError>(async () => {
const guardians = new Guardians();
await guardians.onboardUserAsync(parentUser, body, task);
}, async (error) => {
await this.logger.error(error, ['API_GATEWAY'], parentUser?.id);
taskManager.addError(task.taskId, { code: error.code || 500, message: error.message });
});

return task;
}

/**
* Login
*/
Expand Down
51 changes: 49 additions & 2 deletions api-gateway/src/api/service/task.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { IAuthUser, PinoLogger } from '@guardian/common';
import { Controller, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags, ApiParam, ApiOperation, ApiExtraModels, ApiOkResponse, ApiInternalServerErrorResponse } from '@nestjs/swagger';
import { Controller, Get, HttpCode, HttpStatus, HttpException, Param } from '@nestjs/common';
import { ApiTags, ApiParam, ApiOperation, ApiExtraModels, ApiOkResponse, ApiInternalServerErrorResponse, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { AuthUser, Auth } from '#auth';
import { Examples, InternalServerErrorDTO, TaskStatusDTO } from '#middlewares';
import { InternalException, TaskManager } from '#helpers';
Expand Down Expand Up @@ -60,4 +60,51 @@ export class TaskApi {
await InternalException(error, this.logger, user.id);
}
}

/**
* Get user onboard task status
*/
@Get('/onboard/:taskId')
@ApiOperation({
summary: 'Returns task status of user onboarding by Id without authentication.',
description:
'Returns task status of user onboarding by Id. No Bearer token required.',
})
@ApiParam({
name: 'taskId',
type: String,
description: 'Task Id returned by the initiating endpoint',
required: true,
example: Examples.UUID,
})
@ApiOkResponse({
description: 'Successful operation.',
type: TaskStatusDTO,
})
@ApiUnauthorizedResponse({
description: 'Task exists but is not an onboarding task.',
type: InternalServerErrorDTO,
})
@ApiInternalServerErrorResponse({
description: 'Internal server error.',
type: InternalServerErrorDTO,
})
@ApiExtraModels(TaskStatusDTO, InternalServerErrorDTO)
@HttpCode(HttpStatus.OK)
async getTaskStatus(
@Param('taskId') taskId: string,
): Promise<any> {
try {
const taskManager = new TaskManager();
return taskManager.getOnboardingTask(taskId);
} catch (error) {
if (error?.code === 'TASK_NOT_ONBOARDING') {
throw new HttpException(
'Unauthorized: this API only exposes onboarding tasks.',
HttpStatus.UNAUTHORIZED,
);
}
await InternalException(error, this.logger, null);
}
}
}
17 changes: 16 additions & 1 deletion api-gateway/src/helpers/guardians.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ import {
PolicyPreviewDTO,
ProfileDTO,
PolicyKeyDTO,
ToolVersionDTO
ToolVersionDTO,
OnboardingDTO
} from '#middlewares';

/**
Expand Down Expand Up @@ -559,6 +560,20 @@ export class Guardians extends NatsService {
return await this.sendMessage(MessageAPI.CREATE_USER_PROFILE_COMMON_ASYNC, { user, username, profile, task });
}

/**
* Onboard a new user in a single async call.
* @param parentUser - the authenticated parent (Standard Registry) or null in demo mode
* @param payload - OnboardingDTO fields
* @param task - task tracking object
*/
public async onboardUserAsync(
parentUser: IAuthUser | null,
payload: OnboardingDTO,
task: NewTask
): Promise<NewTask> {
return await this.sendMessage(MessageAPI.ONBOARD_USER_ASYNC, { parentUser, payload, task });
}

/**
* Restore user profile async
* @param username
Expand Down
42 changes: 42 additions & 0 deletions api-gateway/src/helpers/task-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class TaskManager {
[TaskAction.PREVIEW_SCHEMA_MESSAGE, 4],
[TaskAction.CREATE_RANDOM_KEY, 3],
[TaskAction.CONNECT_USER, 9],
[TaskAction.ONBOARD_USER, 9],
[TaskAction.PREVIEW_POLICY_MESSAGE, 4],
[TaskAction.CREATE_TOKEN, 4],
[TaskAction.ASSOCIATE_TOKEN, 4],
Expand Down Expand Up @@ -337,6 +338,47 @@ export class TaskManager {
}
}

/**
* Transfer task ownership to a different user.
* @param taskId
* @param newUserId
* @returns {void}
*/
public transferOwnership(taskId: string, newUserId: string): void {
const task = this.tasks[taskId];
if (task) {
task.userId = newUserId;
}
}

/**
* Return a sanitized onboarding task status by taskId
* @param taskId
* @returns {object} - task data
*/
public getOnboardingTask(taskId: string): object | undefined {
const task = this.tasks[taskId];
if (!task) {
return undefined;
}
if (task.action !== TaskAction.ONBOARD_USER) {
const err: any = new Error('This API only exposes onboarding tasks.');
err.code = 'TASK_NOT_ONBOARDING';
throw err;
}
// Strip sensitive result fields
return {
taskId: task.taskId,
action: task.action,
expectation: task.expectation,
completed: task.result != null,
failed: task.error != null,
error: task.error
? { message: task.error.message ?? 'Task failed' }
: null,
};
}

/**
* Return expectation for task
* @param action
Expand Down
Loading
Loading