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
20 changes: 12 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,26 @@ Wails v2 desktop app (`ayo`): Go 1.24 backend + React 18 / TypeScript / Vite 3 /
The Go backend is tiered under `internal/`:

- `internal/features/` — business logic, one package per feature:
- `auth/` — Register/Login/ResetPassword/Logout + in-memory session (`MasterKey`). bcrypt + Argon2 + AES-GCM. Layered as `dto.go` / `model.go` / `repository.go` / `service.go`.
- `settings/` — per-user settings stored in the OS keyring (`zalando/go-keyring`), encrypted with the session master key. Keyring persistence is in `repository.go`; cloud-key types in `cloud.go`; validated Wails-bound input in `dto.go`.
- `auth/` — Register/Login/ResetPassword/Logout + in-memory session (`MasterKey`). bcrypt + Argon2 + AES-GCM. Layered as `dto.go` / `model.go` / `repository.go` / `service.go`. The service owns the active per-user database connection (via `internal/clients/db`'s `Connection`) and opens/closes it on login/logout; the full DB config (incl. PostgreSQL password) stays on the service, never in `Session` (which is serialized to the frontend).
- `dbconfig/` — dual-encrypted (password-KEK + recovery-KEK) per-user database credentials stored in the OS keyring under `ayo`/`dbcreds_{username}`. `model.go` / `crypto.go` / `repository.go`.
- `settings/` — per-user settings stored in the OS keyring (`zalando/go-keyring`), encrypted with the session master key. Keyring persistence is in `repository.go`; cloud-key types in `cloud.go`; validated Wails-bound input in `dto.go`. `GetDatabaseInfo()` returns sanitized (no password) DB info for the read-only Database tab.
- `recovery/` — save-file dialog for downloading the recovery key (shown after register/reset).
- `queue/` — dead code: not wired into `main.go`, and its SQL is MySQL-flavored (`AUTO_INCREMENT`, `JSON` type, `ON UPDATE CURRENT_TIMESTAMP`) and will not run on SQLite. Don't build on it.
- `internal/clients/` — driver-backed client abstractions:
- `db/` — dialect-aware database client (`sqlite` via `modernc.org/sqlite`, `postgresql` via `github.com/lib/pq`). `Config` + `NewClient`/`Validate`, `Client` embeds `*sql.DB` and carries its `Dialect`, `Rebind()` rewrites `?`→`$N` for PostgreSQL, and `Connection` is the shared per-session connection holder that repositories resolve per operation (tables created lazily via `initializeTable`).
- `storage/` — storage provider clients and dispatch (see below).
- `internal/platform/` — infrastructure (never imported by features' business logic directly beyond what the feature's own repository wraps):
- `database/` — SQLite via `modernc.org/sqlite` (pure Go, no cgo). Tables are created idempotently with `CREATE TABLE IF NOT EXISTS` inside each repository's `initializeTable`; there is **no migration tool**.
- `keyring/` — thin wrapper over `zalando/go-keyring`.
- `dialog/` — native Wails save-file dialog wrapper.
- `queue/` — persistent SQLite-backed job queue (one `Job` per queued file, with status + progress). Wired in `main.go` and consumed by the `upload` feature's business logic through a narrow interface; prefer keeping that dependency behind the feature's own repository if possible.
- `queue/` — job queue (one `Job` per queued file, with status + progress) backed by the signed-in user's database. Wired in `main.go` and consumed by the `upload` feature's business logic through a narrow interface; prefer keeping that dependency behind the feature's own repository if possible.
- `internal/shared/` — cross-cutting code:
- `errors/` — sentinel errors with user-facing messages; return these (not wrapped fmt errors) so the frontend can display them. Also the `InternalServerError` type.
- `errors/` — sentinel errors with user-facing messages; return these (not wrapped fmt errors) so the frontend can display them. Also the `InternalServerError` type, `ErrDatabaseUnavailable` and `ErrNoStorageProvider`.
- `crypto/` — Argon2 KEK derivation + AES-256-GCM encrypt/decrypt primitives.
- `main.go` — entrypoint. Wires `auth`, `settings`, `recovery`, `upload` services and binds them to the frontend via `wails.Run`.
- `paths/` — `GetAppDataDir()` for the OS app data directory where per-user SQLite files live.
- `main.go` — entrypoint. Wires `auth`, `settings`, `recovery`, `upload` services and binds them to the frontend via `wails.Run`. No global database: a shared `dbclient.Connection` is created and passed to auth/queue/upload.
- `assets.go` — `//go:embed all:frontend/dist`; the compiled frontend is embedded into the Go binary.
- `frontend/` — React SPA. Calls Go through generated bindings (below). `@/` aliases `frontend/src`.
- `data/` — gitignored runtime data (`ayo.db`, `chunks/`, `input/`, `output/`). DB is hardcoded to `data/ayo.db` in `main.go`.
- `data/` — gitignored runtime data (`encrypted/`, `downloads/`). Per-user databases live in the OS app data directory, not here.
- `explore/` — stray experiment dir, not part of the build.

## Commands
Expand All @@ -43,5 +47,5 @@ The Go backend is tiered under `internal/`:
## Conventions

- Auth validation uses `go-playground/validator` with a custom `password_strength` rule: passwords must contain upper + lower + digit + symbol.
- SQLite queries use `?` placeholders (see `internal/features/auth/repository.go`).
- Repositories write queries with `?` placeholders and run them through the client's `Rebind()` (a no-op on SQLite, `?`→`$N` on PostgreSQL). `initializeTable` and insert-ID retrieval (`LastInsertId` vs `RETURNING id`) branch on the dialect.
- Frontend: TypeScript, ESLint, Prettier; commit formatted/linted code (`format` + `lint` pass in CI).
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ flowchart TB

Ayo is in the early stages of development.

## Database selection

Each account picks its database at registration (tabbed choice in the sign-up form):

- **SQLite**: a local database file per user, auto-created in the OS app data
directory (`~/Library/Application Support/ayo/<username>.db` on macOS).
- **PostgreSQL**: connect to your own server with user-provided credentials.

The credentials are encrypted in the OS keyring (dual-wrapped with the
password-derived and recovery-key-derived KEKs, the same pattern as the master
key), so password reset re-encrypts them automatically. The database choice is
permanent and shown read-only in Settings → Database.

> **Breaking change**: accounts created before this feature have no encrypted
> database-credentials entry in the keyring and cannot log in after upgrading.
> No automatic migration is provided.

## Getting started

- Dev: `wails dev` (Vite hot reload; browser dev server on http://localhost:34115)
Expand Down
211 changes: 211 additions & 0 deletions frontend/src/components/items/DatabaseConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Database, Server } from 'lucide-react';
import TextInput from '@/components/bits/Input';
import Button from '@/components/bits/Button';

export type DatabaseType = 'sqlite' | 'postgresql';

// The database configuration chosen by the user during registration. SQLite
// needs no fields (the path is auto-generated in the OS app data directory);
// PostgreSQL requires connection details.
export type DatabaseConfigData = {
type: DatabaseType;
host?: string;
port?: number;
database?: string;
username?: string;
password?: string;
};

const postgresSchema = z.object({
host: z.string().min(1, 'Host is required'),
port: z
.string()
.regex(/^\d+$/, 'Port must be a number')
.refine((v) => {
const n = Number(v);
return n >= 1 && n <= 65535;
}, 'Port must be between 1 and 65535'),
database: z.string().min(1, 'Database name is required'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
});

type PostgresFormData = z.infer<typeof postgresSchema>;

export default function DatabaseConfig({
onComplete,
onBack,
}: {
onComplete: (data: DatabaseConfigData) => void;
onBack?: () => void;
}) {
const [type, setType] = useState<DatabaseType>('sqlite');

const {
register,
handleSubmit,
formState: { errors },
} = useForm<PostgresFormData>({
resolver: zodResolver(postgresSchema),
defaultValues: {
host: 'localhost',
port: '5432',
database: '',
username: '',
password: '',
},
});

const submitPostgres = (data: PostgresFormData) => {
onComplete({
type: 'postgresql',
host: data.host,
port: Number(data.port),
database: data.database,
username: data.username,
password: data.password,
});
};

const submitSQLite = () => {
onComplete({ type: 'sqlite' });
};

const tabClass = (id: DatabaseType) =>
`px-5 py-3 text-sm font-semibold transition-all duration-200 border-b-2 flex items-center gap-2 ${
type === id
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-400'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:text-slate-400 dark:hover:text-slate-300 dark:hover:border-slate-600'
}`;

return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
Database Configuration
</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Choose where your account and encrypted data will be stored.
</p>
</div>

{/* Tabs */}
<div className="flex border-b-2 border-slate-200 dark:border-slate-700">
<button type="button" className={tabClass('sqlite')} onClick={() => setType('sqlite')}>
<Database className="h-4 w-4" />
SQLite
</button>
<button
type="button"
className={tabClass('postgresql')}
onClick={() => setType('postgresql')}
>
<Server className="h-4 w-4" />
PostgreSQL
</button>
</div>

<div className="rounded-2xl border-2 border-slate-200 bg-white/90 backdrop-blur-sm p-6 shadow-lg dark:border-slate-700 dark:bg-slate-800/90">
{type === 'sqlite' ? (
<div className="rounded-xl border-2 border-sky-200 bg-gradient-to-br from-sky-50 to-blue-50 p-5 dark:border-sky-800 dark:from-sky-950/30 dark:to-blue-950/30">
<div className="flex gap-3">
<div className="rounded-xl bg-sky-100 p-2.5 dark:bg-sky-900/30">
<Database className="h-5 w-5 text-sky-600 dark:text-sky-400" />
</div>
<div>
<p className="text-sm font-semibold text-sky-900 dark:text-sky-100">
Local storage
</p>
<p className="mt-1 text-sm text-sky-800 dark:text-sky-200 leading-relaxed">
Your data will be stored locally. The database file will be created automatically
in the app data directory. No setup required.
</p>
</div>
</div>
</div>
) : (
<form onSubmit={handleSubmit(submitPostgres)} className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<TextInput
id="db-host"
label="Host"
type="text"
placeholder="localhost"
error={errors.host?.message}
{...register('host')}
/>
<TextInput
id="db-port"
label="Port"
type="number"
placeholder="5432"
error={errors.port?.message}
{...register('port')}
/>
</div>
<TextInput
id="db-database"
label="Database"
type="text"
placeholder="Database name"
error={errors.database?.message}
{...register('database')}
/>
<TextInput
id="db-username"
label="Username"
type="text"
autoComplete="off"
placeholder="Database user"
error={errors.username?.message}
{...register('username')}
/>
<TextInput
id="db-password"
label="Password"
type="password"
placeholder="Database password"
error={errors.password?.message}
{...register('password')}
/>

<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<p className="text-sm text-amber-800 dark:text-amber-200 leading-relaxed">
Ayo will verify the database is reachable before creating your account. Your
connection details are encrypted and stored securely on this device.
</p>
</div>

<div className="flex gap-3 pt-2">
{onBack && (
<Button type="button" variant="ghost" onClick={onBack}>
Back
</Button>
)}
<Button type="submit" fullWidth>
Continue
</Button>
</div>
</form>
)}
</div>

{type === 'sqlite' && (
<div className="flex gap-3">
{onBack && (
<Button type="button" variant="ghost" onClick={onBack}>
Back
</Button>
)}
<Button type="button" fullWidth onClick={submitSQLite}>
Continue
</Button>
</div>
)}
</div>
);
}
Loading
Loading