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
37 changes: 26 additions & 11 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni
| Area | Convention | Example |
| ------------------ | ------------------------------------------ | -------------------------------------------------------- |
| **Backend** | Use case with `execute()` method | `class GetUploadUrlUseCase { async execute(gameId) {} }` |
| **Imports** | Path aliases only (no relative imports) | `import { User } from "@domain/entities"` |
| **Imports** | Prefer path aliases for cross-module imports; allow relative imports for same-directory files | `import { User } from "@domain/entities"` |
| **DI** | Constructor injection of repositories | `constructor(private saveRepo: SaveRepository)` |
| **Validation** | TypeBox schemas in interfaces layer | TypeScript strict mode enabled |
| **Tauri Commands** | `#[tauri::command] pub async fn name() {}` | Must register in `ipc/handlers.rs` |
Expand All @@ -90,9 +90,10 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni

### Import Rules

- Use path aliases: `@domain`, `@application`, `@infrastructure`
- Import from index files: `from "@domain"` or `from "@domain/entities"`
- Never use relative paths beyond same directory: `import "./foo"` → `import from "foo.ts"`
- Use path aliases for imports that cross module boundaries: `@domain`, `@application`, `@infrastructure` (preferred for clarity and tooling). Example: `import { User } from "@domain/entities";`
- Use relative imports only for files that live in the same directory (sibling files). Example: `import { Component } from "./Component";`
- Import from index files when convenient: `import { Thing } from "@domain";` or `import { Thing } from "./index";`
- Avoid mixing alias and relative styles inside the same module; choose one convention per directory to keep imports consistent. When in doubt, prefer the alias for cross-module boundaries and the relative form for local (same-directory) references.

---

Expand Down Expand Up @@ -132,12 +133,12 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni
| Issue | Impact | Prevention |
| --------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------- |
| Unregistered Tauri commands | Command fails silently | Always update `src-tauri/src/ipc/handlers.rs` `generate_handler![]` macro |
| Missing capabilities.json | Permission denied silently | Add capability for any new Tauri feature |
| Missing capabilities.json | Permission denied silently | Add capability for any new Tauri feature. When implementing Rust features that touch the filesystem, network, or OS processes, prompt the user to ensure the required capability is added to `tauri.conf.json` or `capabilities.json` (if you cannot edit it directly). |
| S3 presigned URL throttling | 503 SlowDown at 500+ concurrent requests | Use concurrency limits (e.g., `PRESIGN_CONCURRENCY=50`) |
| API Gateway auth cache | 401 leaked between requests | Use Host as identity source, disable TTL cache |
| Non-owned types in async Tauri commands | Won't compile | Always use `String`, `Vec<T>`, never `&str` or `&[T]` |
| Token secret mismatch | Auth fails across Lambda invocations | Store in AWS SSM Parameter Store or external vault |
| Relative imports mixing | TypeScript confusion | Always use path aliases; never mix relative/absolute |
| Relative imports mixing | TypeScript confusion | Prefer path aliases for inter-module imports; allow relative imports for same-directory files. Avoid mixing styles within the same module to reduce confusion. |

### Concurrency & AWS Limits

Expand All @@ -150,14 +151,28 @@ Mirrors backend architecture with CLI-specific concerns (config I/O, path scanni

## Testing

**Important**: No test framework currently configured — Manual testing only.
**Important**: No test framework is currently configured in this repository — testing is manual by default. If you (or a collaborator) choose to add automated tests later, you must update project configuration, add test scripts, and adjust CI before running them.

When implementing tests:
Recommended frameworks to use when adding tests (only apply these after configuring the runner):

- Backend: **vitest** + **supertest** (Fastify) for use cases, Lambda handlers
- Backend: **vitest** + **supertest** (Fastify) for use cases and Lambda handlers
- Desktop: **vitest** + **React Testing Library** for components
- Rust: Built-in `#[tokio::test]` with mocking
- Focus on edge cases: S3 throttling, token expiry, WebSocket lifecycle, file sync race conditions
- Rust: built-in `#[tokio::test]` with mocking

- Focus on edge cases: S3 presign throttling, token expiry, WebSocket lifecycle, file sync race conditions

If a user or automation requests to run tests but no test framework is configured, politely inform them that no test runner is present and suggest these options:

- Configure a test runner (example commands to install a minimal Vitest setup):

```bash
# Install vitest and supertest for backend
bun add -d vitest supertest

# Add a test script to package.json: "test": "vitest"
```

- Or perform manual validation steps and list what to check (end-to-end flows, critical edge cases) until automated tests are added.

---

Expand Down
96 changes: 96 additions & 0 deletions apps/api/src/application/use-cases/CreateTransferSessionUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import crypto from "crypto";
import type { CloudInviteRepository } from "@domain/ports/CloudInviteRepository";
import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository";

export interface CreateTransferSessionInput {
requesterUserId: string;
targetUserId: string;
targetDeviceId: string;
gameKey: string;
manifestHash: string;
}

export interface TransferSessionResult {
sessionId: string;
token: string;
expiresAt: string;
targetUserId: string;
targetDeviceId: string;
gameKey: string;
manifestHash: string;
}

const SESSION_TTL_MS = 5 * 60 * 1000;

export class CreateTransferSessionUseCase {
constructor(
private readonly inventoryRepository: GameInventoryRepository,
private readonly cloudInviteRepository: CloudInviteRepository
) {}

async execute(input: CreateTransferSessionInput): Promise<TransferSessionResult> {
const requesterId = input.requesterUserId.trim();
const targetUserId = input.targetUserId.trim();
const targetDeviceId = input.targetDeviceId.trim();
const gameKey = input.gameKey.trim();
const manifestHash = input.manifestHash.trim();

if (!requesterId || !targetUserId || !targetDeviceId || !gameKey || !manifestHash) {
throw new Error("Invalid transfer session input");
}

await this.assertSameCloud(requesterId, targetUserId);

const record = await this.inventoryRepository.getDeviceRecord(targetUserId, targetDeviceId);
if (!record?.sharingEnabled) {
throw new Error("Target device inventory not found or sharing disabled");
}

const game = record.games.find((g) => g.gameKey === gameKey && g.status === "verified");
if (!game || game.manifestHash !== manifestHash) {
throw new Error("Game manifest mismatch on target device");
}

const sessionId = crypto.randomUUID();
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();

await this.inventoryRepository.putTransferSession({
sessionId,
token,
requesterUserId: requesterId,
targetUserId,
targetDeviceId,
gameKey,
manifestHash,
expiresAt,
});

return {
sessionId,
token,
expiresAt,
targetUserId,
targetDeviceId,
gameKey,
manifestHash,
};
}

private async assertSameCloud(requesterId: string, targetUserId: string): Promise<void> {
if (requesterId === targetUserId) {
throw new Error("Cannot transfer from yourself");
}

const requesterHosts = await this.cloudInviteRepository.listMembershipsForMember(requesterId);
const active = requesterHosts.find((m) => m.active);
const hostUserId = active?.hostUserId ?? requesterId;

const members = await this.cloudInviteRepository.listMembershipsForHost(hostUserId);
const peerIds = new Set([hostUserId, ...members.filter((m) => m.active).map((m) => m.memberUserId)]);

if (!peerIds.has(targetUserId)) {
throw new Error("Target user is not in your cloud");
}
}
}
51 changes: 51 additions & 0 deletions apps/api/src/application/use-cases/ListGameProvidersUseCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { CloudInviteRepository } from "@domain/ports/CloudInviteRepository";
import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository";
import type { GameProviderDevice } from "@domain/entities/GameInventory";

export interface ListGameProvidersInput {
requesterUserId: string;
gameKey: string;
}

export interface ListGameProvidersResult {
gameKey: string;
providers: GameProviderDevice[];
}

export class ListGameProvidersUseCase {
constructor(
private readonly inventoryRepository: GameInventoryRepository,
private readonly cloudInviteRepository: CloudInviteRepository
) {}

async execute(input: ListGameProvidersInput): Promise<ListGameProvidersResult> {
const requesterId = input.requesterUserId.trim();
const gameKey = input.gameKey.trim();
if (!requesterId || !gameKey) {
throw new Error("requesterUserId and gameKey are required");
}

const hostUserId = await this.resolveActiveHostUserId(requesterId);
const providers = await this.inventoryRepository.listProvidersForGame(hostUserId, gameKey, requesterId);

const enriched = await Promise.all(
providers.map(async (p) => {
const record = await this.inventoryRepository.getDeviceRecord(p.userId, p.deviceId);
const game = record?.games.find((g) => g.gameKey === gameKey);
return {
...p,
files: game?.files ?? [],
};
})
);

return { gameKey, providers: enriched };
}

private async resolveActiveHostUserId(userId: string): Promise<string> {
const memberships = await this.cloudInviteRepository.listMembershipsForMember(userId);
const active = memberships.find((m) => m.active);
if (active) return active.hostUserId;
return userId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository";
import type { TransferSessionRecord } from "@domain/ports/GameInventoryRepository";

export class ListPendingTransferSessionsUseCase {
constructor(private readonly repository: GameInventoryRepository) {}

async execute(targetDeviceId: string): Promise<TransferSessionRecord[]> {
const deviceId = targetDeviceId.trim();
if (!deviceId) throw new Error("deviceId is required");
return this.repository.listPendingTransferSessions(deviceId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { GameInventoryRepository, PublishDeviceInventoryInput } from "@domain/ports/GameInventoryRepository";

export class PublishDeviceInventoryUseCase {
constructor(private readonly repository: GameInventoryRepository) {}

async execute(input: PublishDeviceInventoryInput): Promise<void> {
if (!input.userId.trim() || !input.deviceId.trim()) {
throw new Error("userId and deviceId are required");
}
if (!input.sharingEnabled) {
await this.repository.deleteDeviceInventory(input.userId, input.deviceId);
return;
}
const verified = input.games.filter((g) => g.status === "verified" && g.gameKey.trim());
if (verified.length !== input.games.length) {
throw new Error("Only verified game entries may be published");
}
await this.repository.putDeviceInventory({ ...input, games: verified });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { GameInventoryRepository } from "@domain/ports/GameInventoryRepository";

export class RecordInventoryHeartbeatUseCase {
constructor(private readonly repository: GameInventoryRepository) {}

async execute(userId: string, deviceId: string, appVersion?: string): Promise<void> {
await this.repository.recordHeartbeat(userId.trim(), deviceId.trim(), appVersion);
}
}
57 changes: 57 additions & 0 deletions apps/api/src/domain/entities/GameInventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export interface InventoryFileEntry {
relativePath: string;
size: number;
hash: string;
}

export interface SourcesArchiveEntry {
jobId: string;
relativePath: string;
size: number;
hash: string;
verifiedAt: string;
}

export interface GameInventoryEntry {
gameKey: string;
displayName: string;
status: string;
payloadKind: string;
totalBytes: number;
fileCount: number;
manifestHash: string;
verifiedAt: string;
files: InventoryFileEntry[];
sourcesArchive?: SourcesArchiveEntry;
}

export interface DeviceInventoryRecord {
deviceId: string;
userId: string;
deviceName: string;
manifestVersion: number;
contentHash: string;
updatedAt: string;
sharingEnabled: boolean;
lastSeenAt?: string;
appVersion?: string;
games: GameInventoryEntry[];
}

export interface GameProviderDevice {
userId: string;
deviceId: string;
deviceName: string;
totalBytes: number;
payloadKind: string;
manifestHash: string;
verifiedAt: string;
lastSeenAt?: string;
files?: InventoryFileEntry[];
}

export interface GameIndexEntry {
version: 1;
gameKey: string;
devices: GameProviderDevice[];
}
34 changes: 34 additions & 0 deletions apps/api/src/domain/ports/GameInventoryRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { DeviceInventoryRecord, GameInventoryEntry, GameProviderDevice } from "@domain/entities/GameInventory";

export interface PublishDeviceInventoryInput {
userId: string;
deviceId: string;
deviceName: string;
manifestVersion: number;
contentHash: string;
updatedAt: string;
sharingEnabled: boolean;
games: GameInventoryEntry[];
}

export interface TransferSessionRecord {
sessionId: string;
token: string;
requesterUserId: string;
targetUserId: string;
targetDeviceId: string;
gameKey: string;
manifestHash: string;
expiresAt: string;
}

export interface GameInventoryRepository {
putDeviceInventory(input: PublishDeviceInventoryInput): Promise<void>;
deleteDeviceInventory(userId: string, deviceId: string): Promise<void>;
recordHeartbeat(userId: string, deviceId: string, appVersion?: string): Promise<void>;
listProvidersForGame(hostUserId: string, gameKey: string, excludeUserId?: string): Promise<GameProviderDevice[]>;
getDeviceRecord(userId: string, deviceId: string): Promise<DeviceInventoryRecord | null>;
putTransferSession(record: TransferSessionRecord): Promise<void>;
listPendingTransferSessions(targetDeviceId: string): Promise<TransferSessionRecord[]>;
consumeTransferSession(sessionId: string): Promise<void>;
}
Loading