From 5887d7da815e981ccde87de6c66bdabcac26c571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 26 Mar 2026 16:31:10 +0100 Subject: [PATCH 1/7] feat: widen api_keys.id column from UUID to VARCHAR Support non-UUID API key identifiers (e.g. legacy sandbox keys) by changing the id column type to VARCHAR across migrations, entity, DTOs, and service layer. Adds a new migration to alter existing databases and removes the UUID-only regex filter from key lookups. Includes migration unit tests and an integration test for legacy non-UUID key authentication. --- nilcc-api/migrations/1772193140110-ApiKeys.ts | 2 +- .../migrations/1773000000000-WalletAuth.ts | 3 +- .../1775000000000-ApiKeyIdVarchar.ts | 36 ++++++++++ nilcc-api/src/api-key/api-key.dto.ts | 6 +- nilcc-api/src/api-key/api-key.entity.ts | 2 +- nilcc-api/src/api-key/api-key.service.ts | 6 -- nilcc-api/src/data-source.ts | 2 + nilcc-api/tests/api-key.test.ts | 66 +++++++++++++++++++ nilcc-api/tests/migrations.test.ts | 52 +++++++++++++++ 9 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 nilcc-api/migrations/1775000000000-ApiKeyIdVarchar.ts diff --git a/nilcc-api/migrations/1772193140110-ApiKeys.ts b/nilcc-api/migrations/1772193140110-ApiKeys.ts index ac84efc..e72c968 100644 --- a/nilcc-api/migrations/1772193140110-ApiKeys.ts +++ b/nilcc-api/migrations/1772193140110-ApiKeys.ts @@ -6,7 +6,7 @@ export class ApiKeys1772193140110 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE api_keys ( - id UUID PRIMARY KEY, + id VARCHAR PRIMARY KEY, account_id VARCHAR NOT NULL, type VARCHAR NOT NULL, active BOOLEAN NOT NULL DEFAULT true, diff --git a/nilcc-api/migrations/1773000000000-WalletAuth.ts b/nilcc-api/migrations/1773000000000-WalletAuth.ts index 7e49413..7498ecd 100644 --- a/nilcc-api/migrations/1773000000000-WalletAuth.ts +++ b/nilcc-api/migrations/1773000000000-WalletAuth.ts @@ -13,7 +13,7 @@ export class WalletAuth1773000000000 implements MigrationInterface { await queryRunner.query(` INSERT INTO api_keys (id, account_id, type, active, created_at, updated_at) SELECT - CAST(api_token AS UUID), + api_token, id, 'account-admin', true, @@ -21,7 +21,6 @@ export class WalletAuth1773000000000 implements MigrationInterface { NOW() FROM accounts WHERE api_token IS NOT NULL - AND api_token ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' `); await queryRunner.query("ALTER TABLE accounts DROP COLUMN api_token"); diff --git a/nilcc-api/migrations/1775000000000-ApiKeyIdVarchar.ts b/nilcc-api/migrations/1775000000000-ApiKeyIdVarchar.ts new file mode 100644 index 0000000..5aa7ee3 --- /dev/null +++ b/nilcc-api/migrations/1775000000000-ApiKeyIdVarchar.ts @@ -0,0 +1,36 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +const UUID_PATTERN = + "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"; + +export class ApiKeyIdVarchar1775000000000 implements MigrationInterface { + name = "ApiKeyIdVarchar1775000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE api_keys + ALTER COLUMN id TYPE VARCHAR USING id::VARCHAR + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM api_keys + WHERE id !~* '${UUID_PATTERN}' + ) THEN + RAISE EXCEPTION 'cannot narrow api_keys.id back to UUID while non-UUID API keys exist'; + END IF; + END + $$; + `); + + await queryRunner.query(` + ALTER TABLE api_keys + ALTER COLUMN id TYPE UUID USING id::UUID + `); + } +} diff --git a/nilcc-api/src/api-key/api-key.dto.ts b/nilcc-api/src/api-key/api-key.dto.ts index fab177e..da53e42 100644 --- a/nilcc-api/src/api-key/api-key.dto.ts +++ b/nilcc-api/src/api-key/api-key.dto.ts @@ -5,7 +5,7 @@ export type ApiKeyType = z.infer; export const ApiKey = z .object({ - id: z.string().uuid(), + id: z.string().min(1), accountId: z.string().uuid(), type: ApiKeyType, active: z.boolean(), @@ -26,7 +26,7 @@ export type CreateApiKeyRequest = z.infer; export const UpdateApiKeyRequest = z .object({ - id: z.string().uuid(), + id: z.string().min(1), type: ApiKeyType.optional(), active: z.boolean().optional(), }) @@ -38,7 +38,7 @@ export type UpdateApiKeyRequest = z.infer; export const DeleteApiKeyRequest = z .object({ - id: z.string().uuid(), + id: z.string().min(1), }) .openapi({ ref: "DeleteApiKeyRequest" }); export type DeleteApiKeyRequest = z.infer; diff --git a/nilcc-api/src/api-key/api-key.entity.ts b/nilcc-api/src/api-key/api-key.entity.ts index 6237526..5d6d783 100644 --- a/nilcc-api/src/api-key/api-key.entity.ts +++ b/nilcc-api/src/api-key/api-key.entity.ts @@ -11,7 +11,7 @@ import type { ApiKeyType } from "./api-key.dto"; @Entity({ name: "api_keys" }) export class ApiKeyEntity { - @PrimaryColumn({ type: "uuid" }) + @PrimaryColumn({ type: "varchar" }) id: string; @Column({ type: "varchar" }) diff --git a/nilcc-api/src/api-key/api-key.service.ts b/nilcc-api/src/api-key/api-key.service.ts index b6d8003..aa2044c 100644 --- a/nilcc-api/src/api-key/api-key.service.ts +++ b/nilcc-api/src/api-key/api-key.service.ts @@ -5,9 +5,6 @@ import type { AppBindings } from "#/env"; import type { CreateApiKeyRequest, UpdateApiKeyRequest } from "./api-key.dto"; import { ApiKeyEntity } from "./api-key.entity"; -const UUID_V4_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - export class ApiKeyService { getRepository( bindings: AppBindings, @@ -97,9 +94,6 @@ export class ApiKeyService { bindings: AppBindings, id: string, ): Promise { - if (!UUID_V4_PATTERN.test(id)) { - return null; - } const repository = this.getRepository(bindings); return await repository.findOne({ where: { id, active: true }, diff --git a/nilcc-api/src/data-source.ts b/nilcc-api/src/data-source.ts index 0b5112f..76eb863 100644 --- a/nilcc-api/src/data-source.ts +++ b/nilcc-api/src/data-source.ts @@ -28,6 +28,7 @@ import { Payments1772193140109 } from "migrations/1772193140109-Payments"; import { ApiKeys1772193140110 } from "migrations/1772193140110-ApiKeys"; import { WalletAuth1773000000000 } from "migrations/1773000000000-WalletAuth"; import { UsdBasedPricing1774000000000 } from "migrations/1774000000000-UsdBasedPricing"; +import { ApiKeyIdVarchar1775000000000 } from "migrations/1775000000000-ApiKeyIdVarchar"; import { DataSource } from "typeorm"; import { ApiKeyEntity } from "#/api-key/api-key.entity"; import { NonceEntity } from "#/auth/nonce.entity"; @@ -80,6 +81,7 @@ export async function buildDataSource(config: EnvVars): Promise { ApiKeys1772193140110, WalletAuth1773000000000, UsdBasedPricing1774000000000, + ApiKeyIdVarchar1775000000000, ], synchronize: false, logging: false, diff --git a/nilcc-api/tests/api-key.test.ts b/nilcc-api/tests/api-key.test.ts index 7c05dc4..779b557 100644 --- a/nilcc-api/tests/api-key.test.ts +++ b/nilcc-api/tests/api-key.test.ts @@ -1,5 +1,6 @@ import * as crypto from "node:crypto"; import { describe } from "vitest"; +import { ApiKeyEntity } from "#/api-key/api-key.entity"; import { PathsV1 } from "#/common/paths"; import { createTestFixtureExtension } from "./fixture/it"; @@ -223,6 +224,71 @@ describe("API keys", () => { expect(tierInactiveResponse.status).toBe(401); }); + it("authenticates and manages legacy non-UUID api keys", async ({ + expect, + clients, + app, + bindings, + }) => { + const me = await clients.user.myAccount().submit(); + const legacyKeyId = "sandbox-legacy-api-key"; + const repository = bindings.dataSource.getRepository(ApiKeyEntity); + const now = new Date(); + + await repository.save({ + id: legacyKeyId, + accountId: me.accountId, + type: "account-admin", + active: true, + createdAt: now, + updatedAt: now, + }); + + const listResponse = await app.request( + PathsV1.apiKeys.listByAccount.replace(":accountId", me.accountId), + { + method: "GET", + headers: { + authorization: `Bearer ${legacyKeyId}`, + }, + }, + ); + expect(listResponse.status).toBe(200); + const keys = (await listResponse.json()) as Array<{ id: string }>; + expect(keys.map((key) => key.id)).toContain(legacyKeyId); + + const updateResponse = await app.request(PathsV1.apiKeys.update, { + method: "PUT", + headers: { + authorization: `Bearer ${legacyKeyId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: legacyKeyId, + active: false, + }), + }); + expect(updateResponse.status).toBe(200); + + const tierResponse = await app.request(PathsV1.workloadTiers.list, { + method: "GET", + headers: { + authorization: `Bearer ${legacyKeyId}`, + }, + }); + expect(tierResponse.status).toBe(401); + + const deleteResponse = await app.request(PathsV1.apiKeys.delete, { + method: "POST", + headers: { + "x-api-key": bindings.config.adminApiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: legacyKeyId }), + }); + expect(deleteResponse.status).toBe(200); + }); + it("denies account-admin api key from other accounts", async ({ expect, clients, diff --git a/nilcc-api/tests/migrations.test.ts b/nilcc-api/tests/migrations.test.ts index c16e54d..706c62b 100644 --- a/nilcc-api/tests/migrations.test.ts +++ b/nilcc-api/tests/migrations.test.ts @@ -1,6 +1,8 @@ import type { QueryRunner } from "typeorm"; import { describe, expect, it, vi } from "vitest"; +import { WalletAuth1773000000000 } from "../migrations/1773000000000-WalletAuth"; import { UsdBasedPricing1774000000000 } from "../migrations/1774000000000-UsdBasedPricing"; +import { ApiKeyIdVarchar1775000000000 } from "../migrations/1775000000000-ApiKeyIdVarchar"; describe("UsdBasedPricing1774000000000", () => { it("backfills deposited USD from credited amounts", async () => { @@ -17,3 +19,53 @@ describe("UsdBasedPricing1774000000000", () => { ); }); }); + +describe("WalletAuth1773000000000", () => { + it("migrates all legacy api_token values without UUID filtering", async () => { + const queryRunner = { + query: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryRunner; + + await new WalletAuth1773000000000().up(queryRunner); + + const insertCall = vi + .mocked(queryRunner.query) + .mock.calls.find(([sql]) => sql.includes("INSERT INTO api_keys")); + + expect(insertCall?.[0]).toContain("api_token,"); + expect(insertCall?.[0]).toContain("WHERE api_token IS NOT NULL"); + expect(insertCall?.[0]).not.toContain("CAST(api_token AS UUID)"); + expect(insertCall?.[0]).not.toContain("api_token ~"); + }); +}); + +describe("ApiKeyIdVarchar1775000000000", () => { + it("widens api_keys.id to varchar in up migration", async () => { + const queryRunner = { + query: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryRunner; + + await new ApiKeyIdVarchar1775000000000().up(queryRunner); + + expect(queryRunner.query).toHaveBeenCalledWith( + expect.stringContaining("ALTER COLUMN id TYPE VARCHAR USING id::VARCHAR"), + ); + }); + + it("guards non-UUID keys before narrowing on down migration", async () => { + const queryRunner = { + query: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryRunner; + + await new ApiKeyIdVarchar1775000000000().down(queryRunner); + + expect(queryRunner.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("cannot narrow api_keys.id back to UUID"), + ); + expect(queryRunner.query).toHaveBeenNthCalledWith( + 2, + expect.stringContaining("ALTER COLUMN id TYPE UUID USING id::UUID"), + ); + }); +}); From d34934ca74a9e0459a1d3a67f4d9da2b7fd78500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 26 Mar 2026 16:31:15 +0100 Subject: [PATCH 2/7] feat: add accountIdentityAuthentication middleware Introduce a middleware that accepts both JWT and account-admin API key authentication, rejecting global admin and non-admin API keys. Use it on the GET account endpoint so account-admin keys can retrieve account details. --- nilcc-api/src/account/account.controller.ts | 4 ++-- nilcc-api/src/common/auth.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/nilcc-api/src/account/account.controller.ts b/nilcc-api/src/account/account.controller.ts index b4e13a2..2aa6162 100644 --- a/nilcc-api/src/account/account.controller.ts +++ b/nilcc-api/src/account/account.controller.ts @@ -3,9 +3,9 @@ import { resolver } from "hono-openapi/zod"; import { z } from "zod"; import { accountIdentityAdminAuthentication, + accountIdentityAuthentication, adminAuthentication, assertCanManageIdentityAccount, - jwtAuthentication, } from "#/common/auth"; import { EntityNotFound } from "#/common/errors"; import { microdollarsToUsd } from "#/common/nil"; @@ -169,7 +169,7 @@ export function me(options: ControllerOptions) { ...OpenApiSpecCommonErrorResponses, }, }), - jwtAuthentication(bindings), + accountIdentityAuthentication(bindings), async (c) => { const account = c.get("account"); const outputAccount = accountMapper.entityToResponse(account); diff --git a/nilcc-api/src/common/auth.ts b/nilcc-api/src/common/auth.ts index 111ed9d..b4044b1 100644 --- a/nilcc-api/src/common/auth.ts +++ b/nilcc-api/src/common/auth.ts @@ -71,6 +71,27 @@ export function userAuthentication(bindings: AppBindings) { }; } +export function accountIdentityAuthentication(bindings: AppBindings) { + return async (c: Context, next: Next) => { + const auth = await resolveAuthentication(c, bindings); + if (!auth || auth.principal === AuthPrincipal.GLOBAL_ADMIN) { + return c.json(authError("unauthorized"), 401); + } + + if ( + auth.principal === AuthPrincipal.ACCOUNT_API_KEY && + auth.apiKeyType !== "account-admin" + ) { + return c.json(authError("unauthorized"), 401); + } + + c.set("auth", auth); + c.set("account", auth.account); + await next(); + return; + }; +} + export function jwtAuthentication(bindings: AppBindings) { return async (c: Context, next: Next) => { const token = extractBearerToken(c); From 1a119aa934b35eae898d23a15b6fc32a89b0ef1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 26 Mar 2026 16:31:21 +0100 Subject: [PATCH 3/7] feat: add workload tier update endpoint Add PUT /api/v1/workload-tiers/update for admin users to modify existing tier properties (name, cpus, gpus, memory, disk, cost). Includes DTO validation, service logic with conflict detection, OpenAPI documentation, and integration tests. --- nilcc-api/src/common/paths.ts | 1 + .../workload-tier.controllers.ts | 37 +++++++++++++++++ .../src/workload-tier/workload-tier.dto.ts | 26 ++++++++++++ .../src/workload-tier/workload-tier.router.ts | 1 + .../workload-tier/workload-tier.service.ts | 39 +++++++++++++++++- nilcc-api/tests/fixture/test-client.ts | 9 ++++ nilcc-api/tests/workload-tier.test.ts | 41 ++++++++++++++++++- 7 files changed, 151 insertions(+), 3 deletions(-) diff --git a/nilcc-api/src/common/paths.ts b/nilcc-api/src/common/paths.ts index f099805..71e8307 100644 --- a/nilcc-api/src/common/paths.ts +++ b/nilcc-api/src/common/paths.ts @@ -57,6 +57,7 @@ export const PathsV1 = { workloadTiers: { create: PathSchema.parse("/api/v1/workload-tiers/create"), list: PathSchema.parse("/api/v1/workload-tiers/list"), + update: PathSchema.parse("/api/v1/workload-tiers/update"), delete: PathSchema.parse("/api/v1/workload-tiers/delete"), }, metalInstance: { diff --git a/nilcc-api/src/workload-tier/workload-tier.controllers.ts b/nilcc-api/src/workload-tier/workload-tier.controllers.ts index 3c0de2b..969c654 100644 --- a/nilcc-api/src/workload-tier/workload-tier.controllers.ts +++ b/nilcc-api/src/workload-tier/workload-tier.controllers.ts @@ -5,9 +5,11 @@ import { OpenApiSpecCommonErrorResponses } from "#/common/openapi"; import { PathsV1 } from "#/common/paths"; import type { ControllerOptions } from "#/common/types"; import { payloadValidator } from "#/common/zod-utils"; +import { transactionMiddleware } from "#/data-source"; import { CreateWorkloadTierRequest, DeleteWorkloadTierRequest, + UpdateWorkloadTierRequest, WorkloadTier, } from "./workload-tier.dto"; import { workloadTierMapper } from "./workload-tier.mapper"; @@ -74,6 +76,41 @@ export function list(options: ControllerOptions) { ); } +export function update(options: ControllerOptions) { + const { app, bindings } = options; + app.put( + PathsV1.workloadTiers.update, + describeRoute({ + tags: ["workload-tier"], + summary: "Update a workload tier", + description: "This updates a workload tier.", + responses: { + 200: { + description: "The workload tier was updated successfully", + content: { + "application/json": { + schema: resolver(WorkloadTier), + }, + }, + }, + ...OpenApiSpecCommonErrorResponses, + }, + }), + adminAuthentication(bindings), + payloadValidator(UpdateWorkloadTierRequest), + transactionMiddleware(bindings.dataSource), + async (c) => { + const payload = c.req.valid("json"); + const tier = await bindings.services.workloadTier.update( + bindings, + payload, + c.get("txQueryRunner"), + ); + return c.json(workloadTierMapper.entityToResponse(tier)); + }, + ); +} + export function remove(options: ControllerOptions) { const { app, bindings } = options; app.post( diff --git a/nilcc-api/src/workload-tier/workload-tier.dto.ts b/nilcc-api/src/workload-tier/workload-tier.dto.ts index a95bd5b..b859502 100644 --- a/nilcc-api/src/workload-tier/workload-tier.dto.ts +++ b/nilcc-api/src/workload-tier/workload-tier.dto.ts @@ -49,6 +49,32 @@ export type CreateWorkloadTierRequest = z.infer< typeof CreateWorkloadTierRequest >; +export const UpdateWorkloadTierRequest = z + .object({ + tierId: Uuid.openapi({ description: "The identifier of the tier." }), + name: z.string().openapi({ description: "The name of the tier." }), + cpus: z + .number() + .openapi({ description: "The number of CPUs included in the tier." }), + gpus: z + .number() + .openapi({ description: "The number of GPUs included in the tier." }), + memoryMb: z.number().openapi({ + description: "The amount of MB of RAM included in the tier.", + }), + diskGb: z.number().openapi({ + description: "The amount of GB of disk included in the tier.", + }), + cost: z + .number() + .positive() + .openapi({ description: "The cost per minute in USD for the tier." }), + }) + .openapi({ description: "A request to update a tier." }); +export type UpdateWorkloadTierRequest = z.infer< + typeof UpdateWorkloadTierRequest +>; + export const DeleteWorkloadTierRequest = z .object({ tierId: z.string().openapi({ description: "The tier identifier." }), diff --git a/nilcc-api/src/workload-tier/workload-tier.router.ts b/nilcc-api/src/workload-tier/workload-tier.router.ts index 87ae3f1..da6b5ad 100644 --- a/nilcc-api/src/workload-tier/workload-tier.router.ts +++ b/nilcc-api/src/workload-tier/workload-tier.router.ts @@ -4,5 +4,6 @@ import * as WorkloadTierController from "./workload-tier.controllers"; export function buildWorkloadTierRouter(options: ControllerOptions): void { WorkloadTierController.create(options); WorkloadTierController.list(options); + WorkloadTierController.update(options); WorkloadTierController.remove(options); } diff --git a/nilcc-api/src/workload-tier/workload-tier.service.ts b/nilcc-api/src/workload-tier/workload-tier.service.ts index e083d36..ae96702 100644 --- a/nilcc-api/src/workload-tier/workload-tier.service.ts +++ b/nilcc-api/src/workload-tier/workload-tier.service.ts @@ -1,9 +1,16 @@ import type { QueryRunner, Repository } from "typeorm"; import { v4 as uuidv4 } from "uuid"; -import { EntityAlreadyExists, isUniqueConstraint } from "#/common/errors"; +import { + EntityAlreadyExists, + EntityNotFound, + isUniqueConstraint, +} from "#/common/errors"; import { usdToMicrodollars } from "#/common/nil"; import type { AppBindings } from "#/env"; -import type { CreateWorkloadTierRequest } from "./workload-tier.dto"; +import type { + CreateWorkloadTierRequest, + UpdateWorkloadTierRequest, +} from "./workload-tier.dto"; import { WorkloadTierEntity } from "./workload-tier.entity"; export class WorkloadTierService { @@ -45,6 +52,34 @@ export class WorkloadTierService { await repository.delete({ id }); } + async update( + bindings: AppBindings, + request: UpdateWorkloadTierRequest, + tx: QueryRunner, + ): Promise { + const repository = this.getRepository(bindings, tx); + const tier = await repository.findOneBy({ id: request.tierId }); + if (tier === null) { + throw new EntityNotFound("workload tier"); + } + + tier.name = request.name; + tier.cpus = request.cpus; + tier.gpus = request.gpus; + tier.memory = request.memoryMb; + tier.disk = request.diskGb; + tier.cost = usdToMicrodollars(request.cost); + + try { + return await repository.save(tier); + } catch (e: unknown) { + if (isUniqueConstraint(e)) { + throw new EntityAlreadyExists("workload tier"); + } + throw e; + } + } + async list(bindings: AppBindings): Promise { const repository = this.getRepository(bindings); return await repository.find(); diff --git a/nilcc-api/tests/fixture/test-client.ts b/nilcc-api/tests/fixture/test-client.ts index 8e6e834..beba4c1 100644 --- a/nilcc-api/tests/fixture/test-client.ts +++ b/nilcc-api/tests/fixture/test-client.ts @@ -42,6 +42,7 @@ import { } from "#/workload-event/workload-event.dto"; import { type CreateWorkloadTierRequest, + type UpdateWorkloadTierRequest, WorkloadTier, } from "#/workload-tier/workload-tier.dto"; @@ -177,6 +178,14 @@ export class AdminClient extends TestClient { return new RequestPromise(promise, WorkloadTier); } + updateTier(request: UpdateWorkloadTierRequest): RequestPromise { + const promise = this.request(PathsV1.workloadTiers.update, { + method: "PUT", + body: request, + }); + return new RequestPromise(promise, WorkloadTier); + } + deleteTier(tierId: string): RequestPromise { const promise = this.request(PathsV1.workloadTiers.delete, { method: "POST", diff --git a/nilcc-api/tests/workload-tier.test.ts b/nilcc-api/tests/workload-tier.test.ts index 896a95d..b0c5340 100644 --- a/nilcc-api/tests/workload-tier.test.ts +++ b/nilcc-api/tests/workload-tier.test.ts @@ -1,5 +1,8 @@ import { describe } from "vitest"; -import type { CreateWorkloadTierRequest } from "#/workload-tier/workload-tier.dto"; +import type { + CreateWorkloadTierRequest, + UpdateWorkloadTierRequest, +} from "#/workload-tier/workload-tier.dto"; import { createTestFixtureExtension } from "./fixture/it"; describe("WorkloadTier", () => { @@ -39,4 +42,40 @@ describe("WorkloadTier", () => { expect(await clients.user.listTiers().submit()).toEqual([]); expect(await clients.admin.listTiers().submit()).toEqual([]); }); + + it("should update an existing workload tier", async ({ expect, clients }) => { + const created = await clients.admin + .createTier({ + name: "tier-to-update", + cpus: 1, + memoryMb: 1024, + gpus: 0, + diskGb: 10, + cost: 1, + }) + .submit(); + + const request: UpdateWorkloadTierRequest = { + tierId: created.tierId, + name: "tier-updated", + cpus: 4, + memoryMb: 8192, + gpus: 1, + diskGb: 40, + cost: 7.5, + }; + + const updated = await clients.admin.updateTier(request).submit(); + + expect(updated).toEqual({ + tierId: created.tierId, + name: request.name, + cpus: request.cpus, + memoryMb: request.memoryMb, + gpus: request.gpus, + diskGb: request.diskGb, + cost: request.cost, + }); + expect(await clients.user.listTiers().submit()).toEqual([updated]); + }); }); From 26161094d2d6cc4b64cdf9f3787e8ae44be27984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 26 Mar 2026 16:31:26 +0100 Subject: [PATCH 4/7] feat: add API key and tier update commands to admin CLI Add CRUD commands for API keys (create, list, update, delete) and an update command for workload tiers. Adds a generic PUT method to the API client to support update operations. --- nilcc-admin-cli/src/api.rs | 10 +++ nilcc-admin-cli/src/main.rs | 144 +++++++++++++++++++++++++++++++++- nilcc-admin-cli/src/models.rs | 40 ++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) diff --git a/nilcc-admin-cli/src/api.rs b/nilcc-admin-cli/src/api.rs index c96887e..c5899b6 100644 --- a/nilcc-admin-cli/src/api.rs +++ b/nilcc-admin-cli/src/api.rs @@ -40,6 +40,16 @@ impl ApiClient { Self::handle_response(response) } + pub fn put(&self, path: &str, request: &T) -> Result + where + T: Serialize, + O: DeserializeOwned, + { + let url = self.make_url(path); + let response = self.client.put(url).json(request).send()?; + Self::handle_response(response) + } + fn handle_response(response: Response) -> Result where O: DeserializeOwned, diff --git a/nilcc-admin-cli/src/main.rs b/nilcc-admin-cli/src/main.rs index 853a32a..9bfc1a9 100644 --- a/nilcc-admin-cli/src/main.rs +++ b/nilcc-admin-cli/src/main.rs @@ -1,5 +1,5 @@ use crate::api::{ApiClient, RequestError}; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use serde_json::json; use uuid::Uuid; @@ -31,6 +31,10 @@ enum Command { #[clap(subcommand)] Accounts(AccountsCommand), + /// Manage account API keys. + #[clap(subcommand)] + ApiKeys(ApiKeysCommand), + /// Manage tiers. #[clap(subcommand)] Tiers(TiersCommand), @@ -59,6 +63,44 @@ enum AccountsCommand { Rename(RenameAccountArgs), } +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +enum ApiKeyTypeArg { + #[value(name = "account-admin")] + AccountAdmin, + #[value(name = "user")] + User, +} + +impl ApiKeyTypeArg { + fn as_str(self) -> &'static str { + match self { + Self::AccountAdmin => "account-admin", + Self::User => "user", + } + } +} + +#[derive(Subcommand)] +enum ApiKeysCommand { + /// Create an API key for an account. + Create(CreateApiKeyArgs), + + /// List the API keys for an account. + List { + /// The account id. + account_id: Uuid, + }, + + /// Update an API key. + Update(UpdateApiKeyArgs), + + /// Delete an API key. + Delete { + /// The identifier of the API key to be deleted. + id: Uuid, + }, +} + #[derive(Args)] struct CreateAccountArgs { /// The account name. @@ -90,6 +132,34 @@ struct RenameAccountArgs { name: String, } +#[derive(Args)] +struct CreateApiKeyArgs { + /// The account id. + account_id: Uuid, + + /// The type of API key to create. + #[clap(long, value_enum)] + key_type: ApiKeyTypeArg, + + /// Create the key as inactive. + #[clap(long, default_value_t = false)] + inactive: bool, +} + +#[derive(Args)] +struct UpdateApiKeyArgs { + /// The identifier of the API key to update. + id: Uuid, + + /// Set the API key type. + #[clap(long, value_enum)] + key_type: Option, + + /// Set whether the API key is active. + #[clap(long)] + active: Option, +} + #[derive(Subcommand)] enum TiersCommand { /// Create a tier. @@ -98,6 +168,9 @@ enum TiersCommand { /// List tiers. List, + /// Update a tier. + Update(UpdateTierArgs), + /// Delete a tier. Delete { /// The identifier of the tier to be deleted. @@ -131,6 +204,35 @@ struct CreateTierArgs { disk_gb: u64, } +#[derive(Args)] +struct UpdateTierArgs { + /// The identifier of the tier to be updated. + id: Uuid, + + /// The tier name. + name: String, + + /// The tier cost in USD/minute. + #[clap(long)] + cost: f64, + + /// The number of CPUs that are granted with this tier. + #[clap(long)] + cpus: u64, + + /// The number of GPUs that are granted with this tier. + #[clap(long)] + gpus: u64, + + /// The amount of memory in this tier, in MBs. + #[clap(long)] + memory_mb: u64, + + /// The amount of disk in this tier, in GBs. + #[clap(long)] + disk_gb: u64, +} + #[derive(Subcommand)] enum ArtifactsCommand { /// Enable an artifact version. @@ -192,6 +294,35 @@ impl Runner { self.client.post("/api/v1/accounts/update", &request) } + fn create_api_key(&self, args: CreateApiKeyArgs) -> Result { + let CreateApiKeyArgs { account_id, key_type, inactive } = args; + let request = models::api_keys::CreateApiKeyRequest { + account_id, + r#type: key_type.as_str().to_string(), + active: !inactive, + }; + self.client.post("/api/v1/api-keys/create", &request) + } + + fn list_api_keys(&self, account_id: Uuid) -> Result { + self.client.get(&format!("/api/v1/api-keys/account/{account_id}")) + } + + fn update_api_key(&self, args: UpdateApiKeyArgs) -> Result { + let UpdateApiKeyArgs { id, key_type, active } = args; + let request = models::api_keys::UpdateApiKeyRequest { + id, + r#type: key_type.map(|value| value.as_str().to_string()), + active, + }; + self.client.put("/api/v1/api-keys/update", &request) + } + + fn delete_api_key(&self, id: Uuid) -> Result { + let request = models::api_keys::DeleteApiKeyRequest { id }; + self.client.post("/api/v1/api-keys/delete", &request) + } + fn create_tier(&self, args: CreateTierArgs) -> Result { let CreateTierArgs { name, cost, cpus, gpus, memory_mb, disk_gb } = args; let request = models::tiers::CreateTierRequest { name, cost, cpus, gpus, memory_mb, disk_gb }; @@ -202,6 +333,12 @@ impl Runner { self.client.get("/api/v1/workload-tiers/list") } + fn update_tier(&self, args: UpdateTierArgs) -> Result { + let UpdateTierArgs { id, name, cost, cpus, gpus, memory_mb, disk_gb } = args; + let request = models::tiers::UpdateTierRequest { tier_id: id, name, cost, cpus, gpus, memory_mb, disk_gb }; + self.client.put("/api/v1/workload-tiers/update", &request) + } + fn delete_tier(&self, tier_id: Uuid) -> Result { let request = models::tiers::DeleteTierRequest { tier_id }; self.client.post("/api/v1/workload-tiers/delete", &request) @@ -239,8 +376,13 @@ fn main() { Command::Accounts(AccountsCommand::List) => runner.list_accounts(), Command::Accounts(AccountsCommand::AddBalance(args)) => runner.add_balance(args), Command::Accounts(AccountsCommand::Rename(args)) => runner.rename(args), + Command::ApiKeys(ApiKeysCommand::Create(args)) => runner.create_api_key(args), + Command::ApiKeys(ApiKeysCommand::List { account_id }) => runner.list_api_keys(account_id), + Command::ApiKeys(ApiKeysCommand::Update(args)) => runner.update_api_key(args), + Command::ApiKeys(ApiKeysCommand::Delete { id }) => runner.delete_api_key(id), Command::Tiers(TiersCommand::Create(args)) => runner.create_tier(args), Command::Tiers(TiersCommand::List) => runner.list_tiers(), + Command::Tiers(TiersCommand::Update(args)) => runner.update_tier(args), Command::Tiers(TiersCommand::Delete { id }) => runner.delete_tier(id), Command::Artifacts(ArtifactsCommand::Enable { version }) => runner.enable_artifact_version(version), Command::Artifacts(ArtifactsCommand::List) => runner.list_artifact_versions(), diff --git a/nilcc-admin-cli/src/models.rs b/nilcc-admin-cli/src/models.rs index f5e03d5..50d812e 100644 --- a/nilcc-admin-cli/src/models.rs +++ b/nilcc-admin-cli/src/models.rs @@ -27,6 +27,34 @@ pub mod accounts { } } +pub mod api_keys { + use super::*; + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct CreateApiKeyRequest { + pub account_id: Uuid, + pub r#type: String, + pub active: bool, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct UpdateApiKeyRequest { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active: Option, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct DeleteApiKeyRequest { + pub id: Uuid, + } +} + pub mod tiers { use super::*; @@ -41,6 +69,18 @@ pub mod tiers { pub disk_gb: u64, } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct UpdateTierRequest { + pub tier_id: Uuid, + pub name: String, + pub cost: f64, + pub cpus: u64, + pub gpus: u64, + pub memory_mb: u64, + pub disk_gb: u64, + } + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct DeleteTierRequest { From f353a23400913190b4e406b59df6190c371dda00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 26 Mar 2026 16:31:32 +0100 Subject: [PATCH 5/7] fix: improve test container startup reliability Pin localstack image to v4.14.0 to avoid breaking changes from latest tags. Add TCP/HTTP endpoint readiness checks for all services (postgres, localstack, anvil, mock servers) before proceeding with tests, replacing time-based waits. --- nilcc-api/tests/docker/docker-compose.yml | 2 +- nilcc-api/tests/fixture/global-setup.ts | 75 ++++++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/nilcc-api/tests/docker/docker-compose.yml b/nilcc-api/tests/docker/docker-compose.yml index 4a80d20..843a188 100644 --- a/nilcc-api/tests/docker/docker-compose.yml +++ b/nilcc-api/tests/docker/docker-compose.yml @@ -7,7 +7,7 @@ services: - "35432:5432" localstack: - image: localstack/localstack + image: localstack/localstack:4.14.0 ports: - "14566:4566" diff --git a/nilcc-api/tests/fixture/global-setup.ts b/nilcc-api/tests/fixture/global-setup.ts index 4593e75..d3475f1 100644 --- a/nilcc-api/tests/fixture/global-setup.ts +++ b/nilcc-api/tests/fixture/global-setup.ts @@ -1,4 +1,6 @@ +import { connect } from "node:net"; import { dirname } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import dockerCompose from "docker-compose"; import type { TestProject } from "vitest/node"; @@ -31,18 +33,19 @@ export async function setup(_project: TestProject) { for (; retry < MAX_RETRIES; retry++) { const result = await dockerCompose.ps(composeOptions); if ( - result.data.services.every((service) => service.state.includes("Up")) + result.data.services.every((service) => service.state.includes("Up")) && + (await allEndpointsReady()) ) { break; } - await new Promise((f) => setTimeout(f, 200)); + await sleep(200); } if (retry >= MAX_RETRIES) { console.error("Error starting containers: timeout"); process.exit(1); } // We need sleep 1 sec to be sure that the AboutResponse.started is at least 1 sec earlier than the tests start. - await new Promise((f) => setTimeout(f, 2000)); + await sleep(2000); console.log("Containers started successfully."); } catch (error) { console.error("Error starting containers: ", error); @@ -66,3 +69,69 @@ export async function teardown(_project: TestProject) { process.exit(1); } } + +async function allEndpointsReady(): Promise { + const checks = [ + waitForTcpPort("127.0.0.1", 35432), + waitForHttp("http://127.0.0.1:14566/_localstack/health"), + waitForJsonRpc("http://127.0.0.1:38545"), + waitForHttp("http://127.0.0.1:35435"), + waitForHttp("http://127.0.0.1:35436"), + ]; + + const results = await Promise.all(checks); + return results.every(Boolean); +} + +async function waitForHttp(url: string): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000), + }); + return response.ok; + } catch { + return false; + } +} + +async function waitForJsonRpc(url: string): Promise { + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_chainId", + params: [], + id: 1, + }), + signal: AbortSignal.timeout(1000), + }); + return response.ok; + } catch { + return false; + } +} + +async function waitForTcpPort(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = connect({ host, port }); + + socket.setTimeout(1000); + + socket.once("connect", () => { + socket.end(); + resolve(true); + }); + + socket.once("timeout", () => { + socket.destroy(); + resolve(false); + }); + + socket.once("error", () => { + socket.destroy(); + resolve(false); + }); + }); +} From 9a2adaf47cd53126a11777b1b29dff497fe4f331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 26 Mar 2026 16:31:36 +0100 Subject: [PATCH 6/7] chore: add .vscode/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b9e4927..3bdeec5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ postgres_data/ localstack_data/ nilcc-attester/gpu-attester/verifier.log .env +.vscode/ From 95f979a21475a6e0b7f9f99649a73af636fec8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 26 Mar 2026 17:15:47 +0100 Subject: [PATCH 7/7] fix: use pnpm exec tsc in CI workflow The bare `tsc` command resolves to a system-installed TypeScript on newer GitHub Actions runner images (20260323+), which errors on deprecated tsconfig options. Using `pnpm exec tsc` ensures the project-local TypeScript 5.8.3 is used instead. --- .github/workflows/nilcc-api-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nilcc-api-ci.yml b/.github/workflows/nilcc-api-ci.yml index b7df32a..14b4fa7 100644 --- a/.github/workflows/nilcc-api-ci.yml +++ b/.github/workflows/nilcc-api-ci.yml @@ -27,7 +27,7 @@ jobs: package_json_file: nilcc-api/package.json - run: pnpm install - run: pnpm exec biome ci - - run: tsc + - run: pnpm exec tsc test: needs: check