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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ jobs:
- name: Build
run: npm run build

- name: Check bundle budgets
run: node scripts/bundle-budget.mjs

- name: Validate disaster recovery
run: npm run recovery:validate

- name: Check operational health
run: npm run health:check

- name: Install Playwright browser
run: npx playwright install --with-deps chromium

Expand Down
32 changes: 22 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ or submits a transaction.
The app is a small stable **core** plus any number of independent **feature
slices**. Each tool is one directory under `features/` that owns its logic,
validation, state, UI, tests, fixtures, request mocks and documentation. The
tool registry is *generated* from those directories, so adding a tool requires
tool registry is _generated_ from those directories, so adding a tool requires
creating one new directory and editing nothing else — which is what lets many
contributors work in parallel without ever conflicting.

Expand All @@ -22,15 +22,15 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) and

## Tools

| Tool | What it does |
| --- | --- |
| Address Validator | Validates Stellar addresses and explains exactly why one is rejected |
| Balance Viewer | Every balance an account holds, including pool shares |
| Trustline Checker | Whether an account trusts a specific asset from a specific issuer |
| Payment QR Generator | Builds a SEP-0007 request and renders it as a QR code |
| Transaction Lookup | Ledger, fee, memo, result and operations for a transaction hash |
| Freighter Connect | Detects the wallet and warns about a network mismatch |
| Testnet Faucet | Funds a testnet account through Friendbot |
| Tool | What it does |
| -------------------- | -------------------------------------------------------------------- |
| Address Validator | Validates Stellar addresses and explains exactly why one is rejected |
| Balance Viewer | Every balance an account holds, including pool shares |
| Trustline Checker | Whether an account trusts a specific asset from a specific issuer |
| Payment QR Generator | Builds a SEP-0007 request and renders it as a QR code |
| Transaction Lookup | Ledger, fee, memo, result and operations for a transaction hash |
| Freighter Connect | Detects the wallet and warns about a network mismatch |
| Testnet Faucet | Funds a testnet account through Friendbot |

## Tech stack

Expand Down Expand Up @@ -72,6 +72,9 @@ npm run test:coverage # unit tests with a coverage summary
npm run lint
npm run build
npm run check # everything CI runs
npm run bundle:check # check per-feature bundle size budgets
npm run recovery:validate # validate data integrity after restore
npm run health:check # operational health dashboard
```

## Contributing
Expand All @@ -92,6 +95,15 @@ Maintainers publish contributor work in independent batches of five using the
`coverage/coverage-summary.json`. Coverage is informational and does not
enforce a global percentage threshold.

## Operations and Maintenance

See documentation for operational workflows:

- [RBAC.md](./docs/RBAC.md) - Role-based access control system
- [DISASTER_RECOVERY.md](./docs/DISASTER_RECOVERY.md) - Data validation after restore
- [OPERATIONAL_HEALTH.md](./docs/OPERATIONAL_HEALTH.md) - Health monitoring dashboard
- [BUNDLE_BUDGET.md](./docs/BUNDLE_BUDGET.md) - Per-feature bundle size enforcement

## Security

See [SECURITY.md](./SECURITY.md). Utix is read-only by design.
32 changes: 32 additions & 0 deletions __tests__/bundle-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { existsSync } from 'fs';
import { join } from 'path';

describe('Bundle budget configuration', () => {
it('has bundle baseline file', () => {
const baselinePath = join(process.cwd(), 'bundle-baseline.json');
expect(existsSync(baselinePath)).toBe(true);
});

it('has bundle budget script', () => {
const scriptPath = join(process.cwd(), 'scripts/bundle-budget.mjs');
expect(existsSync(scriptPath)).toBe(true);
});

it('baseline contains valid structure', () => {
const baselinePath = join(process.cwd(), 'bundle-baseline.json');

if (existsSync(baselinePath)) {
const baseline = require(baselinePath);

for (const [feature, data] of Object.entries(baseline)) {
expect(typeof feature).toBe('string');
expect(typeof data.totalSize).toBe('number');
expect(typeof data.sizeKB).toBe('number');
expect(typeof data.timestamp).toBe('string');
expect(data.totalSize).toBeGreaterThan(0);
expect(data.sizeKB).toBeGreaterThan(0);
}
}
});
});
37 changes: 37 additions & 0 deletions bundle-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"address-validator": {
"totalSize": 102400,
"sizeKB": 100,
"timestamp": "2026-09-25T00:00:00.000Z"
},
"balance-viewer": {
"totalSize": 122880,
"sizeKB": 120,
"timestamp": "2026-09-25T00:00:00.000Z"
},
"trustline-checker": {
"totalSize": 92160,
"sizeKB": 90,
"timestamp": "2026-09-25T00:00:00.000Z"
},
"payment-qr": {
"totalSize": 153600,
"sizeKB": 150,
"timestamp": "2026-09-25T00:00:00.000Z"
},
"transaction-lookup": {
"totalSize": 112640,
"sizeKB": 110,
"timestamp": "2026-09-25T00:00:00.000Z"
},
"freighter-connect": {
"totalSize": 71680,
"sizeKB": 70,
"timestamp": "2026-09-25T00:00:00.000Z"
},
"testnet-faucet": {
"totalSize": 81920,
"sizeKB": 80,
"timestamp": "2026-09-25T00:00:00.000Z"
}
}
105 changes: 105 additions & 0 deletions core/auth/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { hasPermission, hasAnyPermission, hasAllPermissions, ROLE_PERMISSIONS } from '../types';
import { requirePermission, UnauthorizedError, getAuthContext } from '../middleware';

describe('Role-based access control', () => {
describe('hasPermission', () => {
it('allows viewers to access public tools', () => {
expect(hasPermission('viewer', 'view:tools')).toBe(true);
expect(hasPermission('viewer', 'view:dashboard')).toBe(true);
});

it('denies viewers from accessing admin functions', () => {
expect(hasPermission('viewer', 'admin:health')).toBe(false);
expect(hasPermission('viewer', 'admin:recovery')).toBe(false);
expect(hasPermission('viewer', 'admin:metrics')).toBe(false);
});

it('allows maintainers to access all functions', () => {
expect(hasPermission('maintainer', 'view:tools')).toBe(true);
expect(hasPermission('maintainer', 'view:dashboard')).toBe(true);
expect(hasPermission('maintainer', 'admin:health')).toBe(true);
expect(hasPermission('maintainer', 'admin:recovery')).toBe(true);
expect(hasPermission('maintainer', 'admin:metrics')).toBe(true);
});

it('allows service accounts limited admin access', () => {
expect(hasPermission('service', 'admin:health')).toBe(true);
expect(hasPermission('service', 'admin:metrics')).toBe(true);
expect(hasPermission('service', 'view:tools')).toBe(false);
expect(hasPermission('service', 'admin:recovery')).toBe(false);
});
});

describe('hasAnyPermission', () => {
it('returns true if user has at least one permission', () => {
expect(hasAnyPermission('viewer', ['view:tools', 'admin:health'])).toBe(true);
expect(hasAnyPermission('service', ['view:tools', 'admin:health'])).toBe(true);
});

it('returns false if user has none of the permissions', () => {
expect(hasAnyPermission('viewer', ['admin:health', 'admin:recovery'])).toBe(false);
});
});

describe('hasAllPermissions', () => {
it('returns true only if user has all permissions', () => {
expect(hasAllPermissions('maintainer', ['view:tools', 'admin:health'])).toBe(true);
expect(hasAllPermissions('viewer', ['view:tools', 'view:dashboard'])).toBe(true);
});

it('returns false if user lacks any permission', () => {
expect(hasAllPermissions('viewer', ['view:tools', 'admin:health'])).toBe(false);
});
});

describe('requirePermission', () => {
it('allows requests with sufficient permissions', () => {
expect(() =>
requirePermission({ role: 'viewer' }, 'view:tools')
).not.toThrow();
});

it('throws UnauthorizedError when permission is denied', () => {
expect(() =>
requirePermission({ role: 'viewer' }, 'admin:health')
).toThrow(UnauthorizedError);
});

it('includes context in error', () => {
try {
requirePermission({ role: 'viewer' }, 'admin:recovery');
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(UnauthorizedError);
const authError = error as UnauthorizedError;
expect(authError.requiredPermission).toBe('admin:recovery');
expect(authError.userRole).toBe('viewer');
}
});
});

describe('getAuthContext', () => {
it('defaults to viewer role', () => {
const context = getAuthContext();
expect(context.role).toBe('viewer');
});
});

describe('ROLE_PERMISSIONS coverage', () => {
it('defines permissions for all roles', () => {
expect(ROLE_PERMISSIONS.viewer).toBeDefined();
expect(ROLE_PERMISSIONS.maintainer).toBeDefined();
expect(ROLE_PERMISSIONS.service).toBeDefined();
});

it('maintains permission hierarchy', () => {
const viewerPerms = ROLE_PERMISSIONS.viewer;
const maintainerPerms = ROLE_PERMISSIONS.maintainer;

viewerPerms.forEach(perm => {
expect(maintainerPerms).toContain(perm);
});
});
});
});
64 changes: 64 additions & 0 deletions core/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Server-side authorization middleware
*
* Enforces permission checks for API routes and server actions.
* These checks run server-side and cannot be bypassed through UI manipulation.
*/

import { Role, Permission, hasPermission } from './types';

export class UnauthorizedError extends Error {
constructor(
message: string,
public readonly requiredPermission: Permission,
public readonly userRole: Role
) {
super(message);
this.name = 'UnauthorizedError';
}
}

export interface AuthContext {
role: Role;
userId?: string;
}

/**
* Verify that the current user has the required permission.
* Throws UnauthorizedError if permission is denied.
*/
export function requirePermission(
context: AuthContext,
permission: Permission
): void {
if (!hasPermission(context.role, permission)) {
throw new UnauthorizedError(
`Permission denied: ${permission} requires elevated privileges`,
permission,
context.role
);
}
}

/**
* Get the current auth context from request headers or environment.
* For Utix, this defaults to viewer role since all tools are read-only.
*/
export function getAuthContext(): AuthContext {
const role = (process.env.UTIX_USER_ROLE as Role) || 'viewer';
return { role };
}

/**
* Higher-order function that wraps server actions with permission checks.
*/
export function withPermission<T extends (...args: any[]) => any>(
permission: Permission,
handler: T
): T {
return (async (...args: Parameters<T>) => {
const context = getAuthContext();
requirePermission(context, permission);
return handler(...args);
}) as T;
}
38 changes: 38 additions & 0 deletions core/auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Role-based access control types for Utix
*
* Utix is read-only by design and does not perform privileged operations.
* These types establish a foundation for future access controls if needed.
*/

export type Role = 'viewer' | 'maintainer' | 'service';

export type Permission =
| 'view:tools'
| 'view:dashboard'
| 'admin:health'
| 'admin:recovery'
| 'admin:metrics';

export interface RolePermissions {
role: Role;
permissions: Permission[];
}

export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
viewer: ['view:tools', 'view:dashboard'],
maintainer: ['view:tools', 'view:dashboard', 'admin:health', 'admin:recovery', 'admin:metrics'],
service: ['admin:health', 'admin:metrics'],
};

export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role].includes(permission);
}

export function hasAnyPermission(role: Role, permissions: Permission[]): boolean {
return permissions.some(p => hasPermission(role, p));
}

export function hasAllPermissions(role: Role, permissions: Permission[]): boolean {
return permissions.every(p => hasPermission(role, p));
}
Loading
Loading