Skip to content
Closed
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
86 changes: 83 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,91 @@ dist-lib
/*.vscode
.codegraph

#env
/*.env
# Environment files (keep sanitized examples and the upstream template)
*.env
.env
.env.*
!.env.example
!.env.template
!.env.sample
!.env.*.example
!.env.*.template
!.env.*.sample

# Private keys and local credentials
.ssh/
.aws/
.gnupg/
id_rsa*
id_dsa*
id_ecdsa*
id_ed25519*
*.pem
*.key
*.p12
*.pfx
*.ppk
.netrc
_netrc
.secrets/
secrets/
secrets.*
credentials
credentials.*
*.credentials.*
keys.json
rclone.conf
rclone.conf.*
restic-password
restic-password.*
restic-passwords/
!secrets.example.*
!credentials.example.*
!rclone.conf.example
!restic-password.example

# Local inventories and deployment overrides; keep examples in docs/examples/
/inventory/
/inventories/
/inventory.ini
/inventory.yml
/inventory.yaml
/hosts.ini
inventory.local.*
inventory.prod.*
inventory.production.*
hosts.local.*
hosts.prod.*
hosts.production.*
docker-compose.override.yml
docker-compose.override.yaml
compose.override.yml
compose.override.yaml
*.local.yml
*.local.yaml

# Generated application/backup data and temporary restore output
/data/
/backups/
/backup-data/
/restic-repositories/
/restic-cache/
/restores/
/restore-staging/
/tmp/
*.db
*.db-wal
*.db-shm
*.sqlite
*.sqlite-wal
*.sqlite-shm
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
*.dump
*.sql.gz
*.sql.bz2

#todos
TODO
*.TODO
*.TODO
73 changes: 73 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Instructions for coding agents

These instructions apply to the entire public fork of `plutonhq/pluton`.

## Public repository and attribution

- Never commit secrets: passwords, API tokens, SSH/private keys, Restic passwords,
SMTP/database credentials, credential files, or decrypted application data.
Do not include them in fixtures, screenshots, logs, documentation, or PR text.
- Never introduce real production identifiers or inventories, including actual
IP addresses, internal hostnames, organization domains, or account usernames.
Use synthetic infrastructure fixtures such as `app-01`, `192.0.2.10`,
`example.internal`, and `/srv/example-app`. Sanitize command output before
sharing it publicly.
- Keep local configuration and generated backup/restore data out of Git. Review
both tracked and untracked changes before committing; `.gitignore` does not
protect files already tracked or replace a review for sensitive content.
- Preserve upstream Apache-2.0 licensing, attribution, copyright notices, and
acknowledgments. Do not rename the application or imply upstream endorsement.
- Never use, copy, reverse engineer, reproduce, or depend on proprietary Pluton
PRO/Business source code or binaries. Implement extensions independently using
this open-source codebase, public Restic/Rclone interfaces, and independently
written code. Public extension hooks do not authorize using proprietary code.

## Workflow and validation

- Work on a feature branch (normally `codex/<task>`), not directly on `main`.
- Inspect the relevant code and summarize findings before modifying it. Follow
existing conventions and keep changes focused on the requested phase.
- Add tests for every new backend behavior, including error paths and permission
boundaries. Use generic fixtures and disposable test data, never production
repositories or infrastructure. Do not weaken existing tests to obtain a pass.
- Use the pnpm version pinned in `package.json` and the existing lockfile. Add
dependencies only when required for the task.
- Run the relevant checks documented in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
Report commands, results, and environment limitations accurately. Do not start
the application against real data merely to validate documentation changes.
- Preserve the existing `.env.template` convention. Example files must contain
placeholders only; do not add settings for features that do not exist yet.

## Restic operation boundaries

- Treat imported/legacy Restic repositories as read-only by default. Initial
adoption must not initialize, migrate, re-key, tag, copy into, or back up to them.
- Clearly separate repository inspection operations from repository mutation
operations in APIs, services, command builders, jobs, tests, and documentation.
A future legacy adapter must enforce an operation allowlist on the backend;
hiding controls in the frontend is insufficient.
- Never automatically run `restic forget`, `restic prune`, or `restic unlock`
against a legacy repository. Initial adoption has no retention, prune, unlock,
repair, or other repository mutation path, even as error recovery.
- Account for lock files: an operation that reads snapshot data can still write
repository locks. Validate supported `--no-lock` behavior and read-only storage
access before allowing a legacy operation. Fail safely if the contract cannot
be met; never fall back to write credentials or automatic unlock.
- Avoid destructive restore behavior by default. Require an explicitly selected,
validated staging directory; reject in-place restore, path escape, overlap with
the repository/live source, and overwrite/delete behavior during initial
adoption. Restoring writes destination files even when the repository is read-only.
- Keep imported repositories separate from managed backup plans and their
initialization, scheduler, retention, replication, and recovery hooks. Never
route legacy access through a managed lifecycle just to reuse its UI.
- Changes to managed backup behavior require a separate, explicitly scoped task
and appropriate regression tests. Preserve existing retention behavior during
repository preparation and legacy design work.

## Phase boundaries

[docs/ROADMAP.md](docs/ROADMAP.md) records planned work, not delivered features.
Phase 0 is documentation and guardrails only: no runtime changes, Restic repository
writes, or implementation of later phases. The legacy design in
[docs/LEGACY_REPOSITORY_DESIGN.md](docs/LEGACY_REPOSITORY_DESIGN.md) is a proposal;
implement only the phase requested in a subsequent task.
120 changes: 120 additions & 0 deletions backend/__tests__/routes/legacyRepositories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import express, { type Express } from 'express';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import Cookies from 'cookies';

jest.mock('jsonwebtoken');
jest.mock('cookies');
jest.mock('../../src/services/ConfigService', () => ({
configService: {
config: {
SECRET: 'legacy-routes-test-secret',
APIKEY: 'legacy-routes-test-api-key',
},
},
}));

import { LegacyRepositoryController } from '../../src/controllers/LegacyRepositoryController';
import { createLegacyRepositoryRouter } from '../../src/routes/legacyRepositories';
import type { LegacyRepositoryService } from '../../src/services/LegacyRepositoryService';

describe('Legacy repository routes', () => {
let app: Express;
let service: {
getAll: jest.Mock;
getById: jest.Mock;
register: jest.Mock;
validate: jest.Mock;
listSnapshots: jest.Mock;
getSnapshot: jest.Mock;
getStats: jest.Mock;
deleteRegistration: jest.Mock;
};

const setAuthenticatedSession = (authenticated: boolean) => {
(Cookies as jest.MockedClass<typeof Cookies>).mockImplementation(
() => ({ get: jest.fn().mockReturnValue(authenticated ? 'test-session' : undefined) }) as never
);
(jwt.verify as jest.Mock).mockImplementation((_token, _secret, callback) => {
callback(authenticated ? null : new Error('invalid session'));
});
};

beforeEach(() => {
jest.clearAllMocks();
service = {
getAll: jest.fn(),
getById: jest.fn(),
register: jest.fn(),
validate: jest.fn(),
listSnapshots: jest.fn(),
getSnapshot: jest.fn(),
getStats: jest.fn(),
deleteRegistration: jest.fn(),
};
app = express();
app.use(express.json());
app.use(
'/api/legacy-repositories',
createLegacyRepositoryRouter(new LegacyRepositoryController(service as unknown as LegacyRepositoryService))
);
setAuthenticatedSession(true);
});

it('requires an authenticated UI session', async () => {
setAuthenticatedSession(false);

const response = await request(app).get('/api/legacy-repositories');

expect(response.status).toBe(401);
expect(service.getAll).not.toHaveBeenCalled();
});

it('does not grant a legacy repository route to an API key', async () => {
setAuthenticatedSession(false);

const response = await request(app)
.get('/api/legacy-repositories')
.set('Authorization', 'Bearer legacy-routes-test-api-key');

expect(response.status).toBe(401);
expect(service.getAll).not.toHaveBeenCalled();
});

it('lists public registration data without credentials', async () => {
service.getAll.mockResolvedValue([
{
id: 'legacy-fixture',
displayName: 'Fixture legacy repository',
repositoryPath: 'C:\\fixtures\\legacy-restic-repository',
backend: 'local',
isReadOnly: true,
validationStatus: 'available',
lastValidatedAt: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: null,
},
]);

const response = await request(app).get('/api/legacy-repositories');

expect(response.status).toBe(200);
expect(response.body).toMatchObject({ success: true });
expect(JSON.stringify(response.body)).not.toContain('password');
});

it('passes exact snapshot filters to the read-only service', async () => {
service.listSnapshots.mockResolvedValue([]);

const response = await request(app)
.get('/api/legacy-repositories/legacy-fixture/snapshots')
.query({ tag: 'application', path: 'C:\\fixtures\\source', host: 'fixture-host' });

expect(response.status).toBe(200);
expect(service.listSnapshots).toHaveBeenCalledWith('legacy-fixture', {
tag: 'application',
path: 'C:\\fixtures\\source',
host: 'fixture-host',
});
});
});
Loading