diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index ac75e63..4302798 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -487,11 +487,11 @@ export class AdminService { // 4. Refund the user's pending shop orders so they drop out of the // fulfilment queue. refundOrder restocks the item and cascade-deletes // the order's fulfilment updates; already-fulfilled orders are left as-is. - // Orders that already have an HCB card grant are excluded: refundOrder - // rejects them (the grant link must be reconciled in HCB by hand), and - // we must not let one such order abort the whole ban flow. + // Orders that already have an HCB card grant or SILO grant are excluded: + // refundOrder rejects them (the grant link must be reconciled by hand), + // and we must not let one such order abort the whole ban flow. const pendingOrders = await this.orderRepo.find({ - where: { userId, status: 'pending', hcbCardGrantId: IsNull() }, + where: { userId, status: 'pending', hcbCardGrantId: IsNull(), siloGrantId: IsNull() }, select: ['id'], }); for (const order of pendingOrders) { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dce4b6c..e9f0ea7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { LapseModule } from './lapse/lapse.module'; import { LookoutModule } from './lookout/lookout.module'; import { HcbModule } from './hcb/hcb.module'; import { SidekickModule } from './sidekick/sidekick.module'; +import { SiloModule } from './silo/silo.module'; import { User } from './entities/user.entity'; import { Session } from './entities/session.entity'; import { Project } from './entities/project.entity'; @@ -69,6 +70,7 @@ import { HealthController } from './health.controller'; LookoutModule, HcbModule, SidekickModule, + SiloModule, ], }) export class AppModule {} diff --git a/backend/src/entities/audit-log.entity.ts b/backend/src/entities/audit-log.entity.ts index b07407d..faefcaf 100644 --- a/backend/src/entities/audit-log.entity.ts +++ b/backend/src/entities/audit-log.entity.ts @@ -33,6 +33,7 @@ export const AUDIT_ACTIONS = [ 'devlog_reviewed', 'hcb_connected', 'card_grant_issued', + 'silo_grant_issued', 'admin_shop_item_change', 'admin_event_change', 'admin_fulfillment_message', diff --git a/backend/src/entities/order.entity.ts b/backend/src/entities/order.entity.ts index efaa039..07018fa 100644 --- a/backend/src/entities/order.entity.ts +++ b/backend/src/entities/order.entity.ts @@ -58,6 +58,11 @@ export class Order { @Column({ name: 'hcb_card_grant_id', type: 'varchar', length: 64, nullable: true }) hcbCardGrantId: string | null; + // SILO grant ID returned by the SILO API for this order, if any. + // Acts as a per-order idempotency lock: a non-null value blocks re-granting. + @Column({ name: 'silo_grant_id', type: 'varchar', length: 64, nullable: true }) + siloGrantId: string | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/backend/src/migrations/1780000000000-AddSiloGrantId.ts b/backend/src/migrations/1780000000000-AddSiloGrantId.ts new file mode 100644 index 0000000..69beb80 --- /dev/null +++ b/backend/src/migrations/1780000000000-AddSiloGrantId.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSiloGrantId1780000000000 implements MigrationInterface { + name = 'AddSiloGrantId1780000000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "orders" ADD "silo_grant_id" varchar(64)`); + await queryRunner.query(`CREATE UNIQUE INDEX "UQ_orders_silo_grant_id" ON "orders"("silo_grant_id") WHERE "silo_grant_id" IS NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "UQ_orders_silo_grant_id"`); + await queryRunner.query(`ALTER TABLE "orders" DROP COLUMN "silo_grant_id"`); + } +} diff --git a/backend/src/shop/shop.service.ts b/backend/src/shop/shop.service.ts index 2e1ce0d..cc7488d 100644 --- a/backend/src/shop/shop.service.ts +++ b/backend/src/shop/shop.service.ts @@ -396,6 +396,7 @@ export class ShopService { 'order.pipesSpent', 'order.status', 'order.hcbCardGrantId', + 'order.siloGrantId', 'order.createdAt', 'order.updatedAt', 'user.id', @@ -432,6 +433,7 @@ export class ShopService { pipesSpent: o.pipesSpent, status: o.status, hcbCardGrantId: o.hcbCardGrantId ?? null, + siloGrantId: o.siloGrantId ?? null, createdAt: o.createdAt, updatedAt: o.updatedAt, userName: o.user?.nickname || o.user?.name || 'Unknown', @@ -622,6 +624,12 @@ export class ShopService { 'already been issued for this order. Reconcile it in HCB instead.', ); } + if (order.siloGrantId) { + throw new ConflictException( + `Cannot refund: a SILO grant (${order.siloGrantId}) has ` + + 'already been issued for this order. Reconcile it in SILO instead.', + ); + } const user = await manager.findOne(User, { where: { id: order.userId }, @@ -730,6 +738,13 @@ export class ShopService { `(${granted.hcbCardGrantId}). Reconcile it in HCB instead.`, ); } + const siloGranted = [target, ...others].find((o) => o.siloGrantId); + if (siloGranted) { + throw new ConflictException( + `Cannot merge: order ${siloGranted.id} has a SILO grant ` + + `(${siloGranted.siloGrantId}). Reconcile it in SILO instead.`, + ); + } let addedQty = 0; let addedPipes = 0; diff --git a/backend/src/silo/silo.controller.ts b/backend/src/silo/silo.controller.ts new file mode 100644 index 0000000..08c4176 --- /dev/null +++ b/backend/src/silo/silo.controller.ts @@ -0,0 +1,51 @@ +import { + Body, + Controller, + Get, + Post, + Param, + ParseUUIDPipe, + Req, + UseGuards, + BadRequestException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { SuperAdminGuard } from '../admin/super-admin.guard'; +import { SiloService, type GrantAdmin } from './silo.service'; + +@Controller('api/admin/silo') +@UseGuards(SuperAdminGuard) +export class SiloController { + constructor(private readonly siloService: SiloService) {} + + private admin(req: Request): GrantAdmin { + const user = (req as any).user; + const uid = user?.uid as string | undefined; + const email = user?.email as string | undefined; + if (!uid || !email) throw new BadRequestException('Not authenticated'); + return { uid, email }; + } + + @Get('status') + status() { + return { configured: this.siloService.isConfigured }; + } + + @Get('prefill/:orderId') + prefill(@Param('orderId', ParseUUIDPipe) orderId: string) { + return this.siloService.buildPrefill(orderId); + } + + @Throttle({ default: { limit: 20, ttl: 60000 } }) + @Post('grant') + async createGrant( + @Req() req: Request, + @Body('orderId') orderId?: string, + ) { + if (!orderId || typeof orderId !== 'string') { + throw new BadRequestException('orderId is required'); + } + return this.siloService.createSiloGrantForOrder(orderId, this.admin(req)); + } +} diff --git a/backend/src/silo/silo.module.ts b/backend/src/silo/silo.module.ts new file mode 100644 index 0000000..4fca3a1 --- /dev/null +++ b/backend/src/silo/silo.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { Order } from '../entities/order.entity'; +import { SiloService } from './silo.service'; +import { SiloController } from './silo.controller'; +import { SuperAdminGuard } from '../admin/super-admin.guard'; + +@Module({ + imports: [ + AuthModule, + RsvpModule, + AuditLogModule, + TypeOrmModule.forFeature([Order]), + ], + controllers: [SiloController], + providers: [SiloService, SuperAdminGuard], + exports: [SiloService], +}) +export class SiloModule {} diff --git a/backend/src/silo/silo.service.ts b/backend/src/silo/silo.service.ts new file mode 100644 index 0000000..6b851f9 --- /dev/null +++ b/backend/src/silo/silo.service.ts @@ -0,0 +1,218 @@ +import { + Injectable, + Logger, + BadRequestException, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { fetchWithTimeout } from '../fetch.util'; +import { Order } from '../entities/order.entity'; +import { AuditLogService } from '../audit-log/audit-log.service'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export type GrantAdmin = { uid: string; email: string }; + +export type SiloGrantPrefill = { + recipientEmail: string; + amount: number; + unit: string; + alreadyGranted: boolean; + existingGrantId: string | null; +}; + +@Injectable() +export class SiloService { + private readonly logger = new Logger(SiloService.name); + + private readonly apiKey: string | undefined; + private readonly baseUrl: string; + private readonly unit: string; + + private static readonly GRANT_AMOUNT = 60; + + constructor( + private readonly config: ConfigService, + @InjectRepository(Order) + private readonly orderRepo: Repository, + private readonly auditLogService: AuditLogService, + ) { + this.apiKey = this.config.get('SILO_API_KEY')?.trim() || undefined; + this.baseUrl = (this.config.get('SILO_BASE_URL') ?? 'https://dash.onsilo.dev').replace(/\/$/, ''); + this.unit = (this.config.get('SILO_GRANT_UNIT') ?? 'GB').trim() || 'GB'; + + if (!this.apiKey) { + this.logger.warn('SILO grants disabled — set SILO_API_KEY'); + } + } + + get isConfigured(): boolean { + return !!this.apiKey; + } + + async buildPrefill(orderId: string): Promise { + const order = await this.orderRepo.findOne({ + where: { id: orderId }, + relations: ['user'], + }); + if (!order) throw new NotFoundException('Order not found'); + + return { + recipientEmail: order.user?.email ?? '', + amount: SiloService.GRANT_AMOUNT, + unit: this.unit, + alreadyGranted: !!order.siloGrantId, + existingGrantId: order.siloGrantId, + }; + } + + /** + * Creates a SILO storage grant for an order. + * + * Safety: + * - The order row is locked FOR UPDATE and the grant id is stamped onto it in + * the same transaction; a null check on siloGrantId makes a duplicate grant + * impossible. + * - The audit log is written AFTER commit, so a failed audit insert can never + * roll back a grant that has already been issued. + * - If the order update fails *after* SILO created the grant, the grant id is + * logged at error level for manual reconciliation. + */ + async createSiloGrantForOrder( + orderId: string, + admin: GrantAdmin, + ): Promise<{ grantId: string; amount: number; unit: string }> { + if (!this.apiKey) { + throw new ServiceUnavailableException('SILO is not configured'); + } + + // Fetch recipient info outside the transaction for the catch handler's + // critical error log (transactionResult isn't assigned on failure). + const orderForRecipient = await this.orderRepo.findOne({ + where: { id: orderId }, + relations: ['user'], + }); + const recipientEmail = (orderForRecipient?.user?.email ?? '').trim().toLowerCase(); + + let issuedGrantId: string | null = null; + let recipientUserId: string | undefined; + let transactionResult: { grantId: string; amount: number; unit: string } | undefined; + + try { + transactionResult = await this.orderRepo.manager.transaction(async (em) => { + const order = await em.findOne(Order, { + where: { id: orderId }, + relations: ['user'], + lock: { mode: 'pessimistic_write' }, + }); + if (!order) throw new NotFoundException('Order not found'); + if (order.siloGrantId) { + throw new ConflictException( + `A SILO grant (${order.siloGrantId}) was already issued for this order`, + ); + } + + const email = (order.user?.email ?? '').trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + throw new BadRequestException('Order owner has no valid email'); + } + if (email === admin.email.trim().toLowerCase()) { + throw new BadRequestException('You cannot issue a SILO grant to your own email'); + } + + const externalId = order.id; + + const body: Record = { + amount: SiloService.GRANT_AMOUNT, + unit: this.unit, + externalId, + reason: order.itemName, + }; + body.userId = order.user?.hcaSub ?? order.user?.slackId ?? email; + + const res = await fetchWithTimeout( + `${this.baseUrl}/api/ysws/grants`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }, + ); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + this.logger.error(`SILO grant failed: ${res.status} ${detail.slice(0, 300)}`); + if (res.status === 400) { + throw new BadRequestException('SILO rejected the grant (check email and configuration)'); + } + if (res.status === 401 || res.status === 403) { + throw new ServiceUnavailableException('SILO API key is not valid'); + } + throw new ServiceUnavailableException('SILO grant request failed'); + } + + const grant = await res.json().catch(() => null); + const grantId = typeof grant?.grant?.id === 'string' ? grant.grant.id : null; + if (!grantId) { + this.logger.error('SILO grant succeeded but response had no id'); + throw new ServiceUnavailableException( + 'SILO returned an unexpected response; verify in SILO before retrying', + ); + } + + issuedGrantId = grantId; + + order.siloGrantId = grantId; + await em.save(order); + + recipientUserId = order.userId; + + return { + grantId, + amount: SiloService.GRANT_AMOUNT, + unit: this.unit, + }; + }); + } catch (err) { + if (issuedGrantId) { + this.logger.error( + `CRITICAL: SILO grant ${issuedGrantId} (${SiloService.GRANT_AMOUNT} ${this.unit} to ${recipientEmail || 'unknown'}) ` + + `was created but order ${orderId} could not be updated. ` + + `Reconcile in SILO before any retry. ` + + `Cause: ${err instanceof Error ? err.message : String(err)}`, + ); + try { + await this.orderRepo.update({ id: orderId }, { siloGrantId: issuedGrantId }); + } catch (stampErr) { + this.logger.error( + `Failed to stamp grant ${issuedGrantId} onto order ${orderId} for retry-safety: ` + + `${stampErr instanceof Error ? stampErr.message : String(stampErr)}`, + ); + } + } + throw err; + } + + try { + await this.auditLogService.log( + recipientUserId ?? orderId, + 'silo_grant_issued', + `SILO grant ${transactionResult.grantId} for ${transactionResult.amount} ${transactionResult.unit} issued to ${recipientEmail || 'unknown'} by ${admin.email}`, + ); + } catch (err) { + this.logger.error( + `Audit log failed for SILO grant ${transactionResult.grantId} (grant itself succeeded): ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { grantId: transactionResult.grantId, amount: transactionResult.amount, unit: transactionResult.unit }; + } +} diff --git a/frontend/src/lib/components/admin/SiloGrantModal.svelte b/frontend/src/lib/components/admin/SiloGrantModal.svelte new file mode 100644 index 0000000..09d960c --- /dev/null +++ b/frontend/src/lib/components/admin/SiloGrantModal.svelte @@ -0,0 +1,244 @@ + + +
{ + if (e.key === 'Escape') onClose(); + }} +> + + +
+ + diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 6c76ef6..744fd56 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -6,6 +6,7 @@ import TimelapsePanel from '$lib/components/admin/TimelapsePanel.svelte'; import CardGrantModal from '$lib/components/admin/CardGrantModal.svelte'; import ItemBuyersModal from '$lib/components/admin/ItemBuyersModal.svelte'; + import SiloGrantModal from '$lib/components/admin/SiloGrantModal.svelte'; import { onMount, tick } from 'svelte'; import { replaceState } from '$app/navigation'; @@ -1639,6 +1640,7 @@ pipesSpent: number; status: string; hcbCardGrantId: string | null; + siloGrantId: string | null; createdAt: string; updatedAt: string; userName: string; @@ -1666,6 +1668,24 @@ let hcbStatus = $state(null); let grantModalOrder = $state(null); + // SILO grants + type SiloStatus = { configured: boolean }; + let siloStatus = $state(null); + let siloGrantModalOrder = $state(null); + + async function loadSiloStatus() { + try { + const res = await fetch('/api/admin/silo/status'); + if (res.ok) siloStatus = await res.json(); + } catch { + /* leave null */ + } + } + + function onSiloGrantIssued() { + loadFulfillment(); + } + async function loadHcbStatus() { try { const res = await fetch('/api/admin/hcb/status'); @@ -1851,7 +1871,7 @@ if (activeTab === 'events') { loadEvents(); if (eventHostUsers.length === 0) loadEventHostUsers(); } if (activeTab === 'projects') { loadProjects(); loadProjectHours(); } if (activeTab === 'shop') loadShop(); - if (activeTab === 'fulfillment') { loadFulfillment(); loadHcbStatus(); } + if (activeTab === 'fulfillment') { loadFulfillment(); loadHcbStatus(); loadSiloStatus(); } if (activeTab === 'leaderboard') loadLeaderboard(); }); @@ -2349,6 +2369,15 @@ {/if} {/if} + {#if siloStatus} +
+ {#if !siloStatus.configured} + ⚠ SILO storage grants are not configured on the server (set SILO_API_KEY). + {:else} + ✓ SILO storage grants enabled + {/if} +
+ {/if}
(buyersModalItem = null)} /> {/if} +{#if siloGrantModalOrder} + (siloGrantModalOrder = null)} + onGranted={() => { siloGrantModalOrder = null; onSiloGrantIssued(); }} + /> +{/if} +