Skip to content
Open
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
12 changes: 12 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ Commit messages prefixed with `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `c
## Hotfixes

For urgent patches, branch from the latest tag, fix, and tag a new `PATCH` version.


## Artifact Retention and Provenance Policy

### Artifact Retention
- **Release Artifacts**: Release binaries, built assets, and container images associated with official releases are retained indefinitely.
- **CI Build Artifacts**: Intermediate build artifacts generated during pull request validation and daily CI runs are retained for 90 days.

### Provenance and Verification
- **Verifiable Builds**: All production-grade build artifacts must be generated via the official CI/CD pipeline (`release.yml`).
- **Chain of Custody**: Releases use GitHub Actions with OpenID Connect (OIDC) to verify build origin. We aim for SLSA compliance, ensuring that build recipes are verifiable, tamper-resistant, and traced back to the exact commit SHA in the repository.
- **On-chain Contracts**: Contract `.wasm` binaries attached to releases must match the output generated by reproducible Soroban builds from the tagged source code.
3 changes: 1 addition & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"@nestjs/websockets": "^11.0.10",
"axios": "^1.0.0",
"backend": "file:",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
Expand Down Expand Up @@ -118,4 +117,4 @@
}
}
}
}
}
2 changes: 1 addition & 1 deletion backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Admin } from './admin.entity';
import { CreateAdminDto } from './dto/create-admin.dto';
import * as bcrypt from 'bcrypt';
import * as bcrypt from 'bcryptjs';
import { JwtService } from '@nestjs/jwt';
import { LoginAdminDto } from './dto/login-admin.dto';
import { AdminRole } from './admin-role.enum';
Expand Down
10 changes: 8 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { OutboxEvent } from "./outbox/entities/outbox-event.entity";
import { OutboxModule } from "./outbox/outbox.module";
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
Expand Down Expand Up @@ -48,6 +51,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service';

@Module({
imports: [
ScheduleModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env'],
Expand Down Expand Up @@ -99,7 +103,8 @@ import { GracefulShutdownService } from './graceful-shutdown.service';
}),
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
imports: [
ScheduleModule.forRoot(),ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
Expand All @@ -108,7 +113,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service';
username: configService.get('database.user'),
password: configService.get('database.password'),
database: configService.get('database.name'),
entities: [User, TimeTrial, Puzzle, Category, Report],
entities: [User, TimeTrial, Puzzle, Category, Report, OutboxEvent],
migrations: [join(__dirname, '**', 'migrations', '*.{ts,js}')],
synchronize: configService.get('database.synchronize') === true,
autoLoadEntities: configService.get('database.autoload') === true,
Expand Down Expand Up @@ -143,6 +148,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service';
UserReactionModule,
UserReportCardModule,
MaintenanceModeModule,
OutboxModule,
],
controllers: [AppController],
providers: [AppService, GracefulShutdownService],
Expand Down
2 changes: 1 addition & 1 deletion backend/src/auth/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
BeforeUpdate,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import * as bcrypt from 'bcrypt';
import * as bcrypt from 'bcryptjs';

const BCRYPT_SALT_ROUNDS = 12;

Expand Down
25 changes: 25 additions & 0 deletions backend/src/outbox/entities/outbox-event.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";

@Entity("outbox_events")
export class OutboxEvent {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column()
type: string;

@Column({ type: "jsonb" })
payload: any;

@Column({ default: "PENDING" })
status: string; // "PENDING", "PROCESSED", "FAILED"

@Column({ nullable: true })
error?: string;

@CreateDateColumn()
createdAt: Date;

@Column({ type: "timestamp", nullable: true })
processedAt?: Date;
}
11 changes: 11 additions & 0 deletions backend/src/outbox/outbox.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { OutboxEvent } from "./entities/outbox-event.entity";
import { OutboxService } from "./outbox.service";

@Module({
imports: [TypeOrmModule.forFeature([OutboxEvent])],
providers: [OutboxService],
exports: [OutboxService],
})
export class OutboxModule {}
48 changes: 48 additions & 0 deletions backend/src/outbox/outbox.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from "@nestjs/typeorm";
import { OutboxService } from "./outbox.service";
import { OutboxEvent } from "./entities/outbox-event.entity";

describe("OutboxService", () => {
let service: OutboxService;

const mockRepository: any = {
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OutboxService,
{
provide: getRepositoryToken(OutboxEvent),
useValue: mockRepository,
},
],
}).compile();

service = module.get<OutboxService>(OutboxService);
});

it("should be defined", () => {
expect(service).toBeDefined();
});

describe("createEvent", () => {
it("should save a new outbox event", async () => {
const payload = { test: "data" };
const event = new OutboxEvent();
event.type = "TEST_EVENT";
event.payload = payload;
event.status = "PENDING";

mockRepository.save.mockResolvedValue(event);

const result = await service.createEvent("TEST_EVENT", payload);
expect(result).toEqual(event);
});
});
});
66 changes: 66 additions & 0 deletions backend/src/outbox/outbox.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, EntityManager } from "typeorm";
import { OutboxEvent } from "./entities/outbox-event.entity";
import { Cron, CronExpression } from "@nestjs/schedule";

@Injectable()
export class OutboxService {
constructor(
@InjectRepository(OutboxEvent)
private readonly outboxRepository: Repository<OutboxEvent>,
) {}

async createEvent(type: string, payload: any, manager?: EntityManager): Promise<OutboxEvent> {
const event = new OutboxEvent();
event.type = type;
event.payload = payload;
event.status = "PENDING";

if (manager) {
return manager.save(event);
}
return this.outboxRepository.save(event);
}

async getPendingEvents(): Promise<OutboxEvent[]> {
return this.outboxRepository.find({
where: { status: "PENDING" },
order: { createdAt: "ASC" },
});
}

async markAsProcessed(id: string): Promise<OutboxEvent> {
const event = await this.outboxRepository.findOne({ where: { id } });
if (!event) {
throw new Error(`Outbox event with ID ${id} not found`);
}
event.status = "PROCESSED";
event.processedAt = new Date();
return this.outboxRepository.save(event);
}

async markAsFailed(id: string, error: string): Promise<OutboxEvent> {
const event = await this.outboxRepository.findOne({ where: { id } });
if (!event) {
throw new Error(`Outbox event with ID ${id} not found`);
}
event.status = "FAILED";
event.error = error;
event.processedAt = new Date();
return this.outboxRepository.save(event);
}

@Cron(CronExpression.EVERY_10_SECONDS)
async processOutbox() {
const events = await this.getPendingEvents();
for (const event of events) {
try {
console.log(`Processing outbox event: ${event.type}`, event.payload);
await this.markAsProcessed(event.id);
} catch (err) {
await this.markAsFailed(event.id, err.message);
}
}
}
}
23 changes: 21 additions & 2 deletions frontend/app/admin/puzzle-review/page.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
'use client';

import { Suspense } from 'react';
import { Suspense, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import useAuthStore from '../../../store/auth/auth-store';
import PuzzleReviewDashboard from '../../../components/admin/puzzle-review/PuzzleReviewDashboard';
import AdminLayout from '../../../components/admin/AdminLayout';
import LoadingSpinner from '../../../components/ui/LoadingSpinner';

export default function AdminPuzzleReviewPage() {
const router = useRouter();
const { user, isAuthenticated } = useAuthStore();
const [authorized, setAuthorized] = useState(false);

useEffect(() => {
const isAdmin = user?.role === 'admin' || user?.roles?.includes('admin');
if (!isAuthenticated || !isAdmin) {
router.push('/login');
} else {
setAuthorized(true);
}
}, [isAuthenticated, user, router]);

if (!authorized) {
return null;
}

return (
<AdminLayout>
<div className="min-h-screen bg-gray-50">
Expand Down Expand Up @@ -34,4 +53,4 @@ export default function AdminPuzzleReviewPage() {
</div>
</AdminLayout>
);
}
}
20 changes: 19 additions & 1 deletion frontend/components/admin/AdminLayout.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
'use client';

import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import useAuthStore from '../../store/auth/auth-store';
import { Shield, Users, FileText, Settings, LogOut, Menu, X } from 'lucide-react';

const AdminLayout = ({ children }) => {
const router = useRouter();
const { user, isAuthenticated } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [authorized, setAuthorized] = useState(false);

useEffect(() => {
const isAdmin = user?.role === 'admin' || user?.roles?.includes('admin');
if (!isAuthenticated || !isAdmin) {
router.push('/login');
} else {
setAuthorized(true);
}
}, [isAuthenticated, user, router]);

if (!authorized) {
return null;
}

const navigation = [
{ name: 'Dashboard', href: '/admin', icon: Shield },
Expand Down
2 changes: 2 additions & 0 deletions onchain/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 1 addition & 7 deletions onchain/contracts/stellar_hunts_nft/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,7 @@ fn test_double_mint_rejected() {
game.mint(&nft_id, &r, &crate::Levels::Easy);
// Second mint must fail (already-has-badge error).
let should_panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
client.init(
&admin,
&game,
&String::from_str(&env, &long_uri),
&String::from_str(&env, "StellarHuntsBadge"),
&String::from_str(&env, "SHB"),
);
game.mint(&nft_id, &r, &crate::Levels::Easy);
}));
assert!(should_panic.is_err());
}
Expand Down
Loading