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
111 changes: 111 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
This file provides guidance to LLM agents when working with this repository.

## Maintenance Instructions

This file should be committed to the repository and kept up to date. When making significant changes to the project structure, dependencies, or development workflows, update this file accordingly. Do not include local-specific information (paths, credentials, personal settings, or references to specific LLM agents).

## Project Overview

This is an SFTP service for Permanent.org that implements the SFTP protocol to support interaction with the Permanent API. It allows users to upload and download files from their Permanent.org archives using standard SFTP clients like rclone.

## Tech Stack

- **Runtime**: Node.js 24.x
- **Language**: TypeScript 5.x
- **SFTP Protocol**: ssh2
- **Authentication**: FusionAuth
- **API Integration**: @permanentorg/sdk
- **Logging**: Winston
- **Error Monitoring**: Sentry
- **Testing**: Jest with ts-jest
- **Linting**: ESLint (eslint-config-love) with Prettier

## Project Structure

```
src/
├── classes/ # Core service classes
│ ├── AuthenticationSession.ts # User authentication session management
│ ├── AuthTokenManager.ts # OAuth token refresh handling
│ ├── PermanentFileSystem.ts # File system operations via Permanent API
│ ├── PermanentFileSystemManager.ts # Manages file system instances
│ ├── SftpSessionHandler.ts # SFTP protocol session handling
│ ├── SshConnectionHandler.ts # SSH connection management
│ ├── SshSessionHandler.ts # SSH session handling
│ └── TemporaryFileManager.ts # Temp file management for uploads
├── errors/ # Custom error classes
├── utils/ # Utility functions for file attributes and entries
├── fusionAuth.ts # FusionAuth client configuration
├── index.ts # Application entry point
├── instrument.ts # Sentry instrumentation
├── logger.ts # Winston logger configuration
└── server.ts # SSH server setup
```

## Common Commands

```bash
# Install dependencies
npm install

# Build the project
npm run build

# Start the service
npm start

# Start development server (with hot reload)
npm run dev

# Run tests
npm test

# Lint the codebase
npm run lint

# Format code
npm run format
```

## Development Setup

1. Install dependencies: `npm install`
2. Generate a host key: `ssh-keygen -f ./keys/host.key -t ed25519 -N ""`
3. Copy and configure environment: `cp .env.example .env`
4. Start development server: `npm run dev`

## Environment Variables

Key environment variables (see `.env.example` for full list):

- `SSH_PORT` / `SSH_HOST` - Server binding configuration
- `SSH_HOST_KEY_PATH` - Path to SSH host key
- `PERMANENT_API_BASE_PATH` - Permanent API endpoint
- `STELA_API_BASE_PATH` - Stela API v2 endpoint
- `FUSION_AUTH_*` - FusionAuth authentication configuration
- `SENTRY_DSN` / `SENTRY_ENVIRONMENT` - Error monitoring

## Workflow Requirements

After making code changes, always run:

```bash
npm run format # Auto-fix formatting issues
npm run lint # Check for linting errors
npm test # Run tests
```

## Code Style

- No default exports (ESLint rule `import/no-default-export`)
- Import ordering enforced (builtin, external, internal, parent, sibling, index, object, type)
- Prettier for formatting
- TypeScript strict mode enabled

## Testing

Tests use Jest with ts-jest preset. Test files should be named `*.test.ts` and placed alongside the source files they test.

```bash
npm test
```
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ grant_type: authorization_code
redirect_uri: http://localhost:9000/auth/callback
```

## Working with an LLM Agent

This project includes an `AGENTS.md` which is intended to be a general file that can be used to help improve the context understood by any LLM agent.

## License

This code is free software licensed as [AGPLv3](LICENSE), or at your option, any final, later version published by the Free Software Foundation.
10 changes: 5 additions & 5 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@ export default defineConfig([
},
{
files: ["**/*.test.ts"],

plugins: {
jest,
},

...jest.configs["flat/recommended"],
},
{
files: ["**/*.test.ts"],
rules: {
"max-lines": "off",
"max-nested-callbacks": "off",
"@typescript-eslint/no-magic-numbers": "off",
},
},
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module.exports = {
testPathIgnorePatterns: ["<rootDir>/lib/"],
roots: ["<rootDir>/src"],
silent: true,
passWithNoTests: true,
preset: "ts-jest",
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"private": true,
"devDependencies": {
"@tsconfig/node24": "^24.0.3",
"@types/jest": "^30.0.0",
"@types/node-fetch": "^2.6.4",
"@types/ssh2": "^1.15.5",
"@types/tmp": "^0.2.6",
Expand Down
71 changes: 71 additions & 0 deletions src/utils/getArchiveSlugFromPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { getArchiveSlugFromPath } from "./getArchiveSlugFromPath";

describe("getArchiveSlugFromPath", () => {
describe("valid paths", () => {
it("extracts slug from basic archive path", () => {
const path = "/archives/My Archive (abc-123)";
expect(getArchiveSlugFromPath(path)).toBe("abc-123");
});

it("extracts slug from path with subfolder", () => {
const path = "/archives/My Archive (abc-123)/subfolder";
expect(getArchiveSlugFromPath(path)).toBe("abc-123");
});

it("extracts slug from path with deep nesting", () => {
const path = "/archives/My Archive (abc-123)/a/b/c";
expect(getArchiveSlugFromPath(path)).toBe("abc-123");
});

it("handles archive names with parentheses", () => {
const path = "/archives/My (Cool) Archive (xyz-789)";
expect(getArchiveSlugFromPath(path)).toBe("xyz-789");
});

it("handles slugs with only letters", () => {
const path = "/archives/Test Archive (abcdef)";
expect(getArchiveSlugFromPath(path)).toBe("abcdef");
});

it("handles slugs with only numbers", () => {
const path = "/archives/Test Archive (123456)";
expect(getArchiveSlugFromPath(path)).toBe("123456");
});
});

describe("invalid paths", () => {
it("throws error for path without archive section", () => {
const path = "/some/other/path";
expect(() => getArchiveSlugFromPath(path)).toThrow(
"The specified path did not contain an archive slug",
);
});

it("throws error for path with missing slug parentheses", () => {
const path = "/archives/My Archive";
expect(() => getArchiveSlugFromPath(path)).toThrow(
"The specified path did not contain an archive slug",
);
});

it("throws error for empty string", () => {
expect(() => getArchiveSlugFromPath("")).toThrow(
"The specified path did not contain an archive slug",
);
});

it("throws error for root archives path", () => {
const path = "/archives";
expect(() => getArchiveSlugFromPath(path)).toThrow(
"The specified path did not contain an archive slug",
);
});

it("throws error for path without space before parentheses", () => {
const path = "/archives/MyArchive(abc-123)";
expect(() => getArchiveSlugFromPath(path)).toThrow(
"The specified path did not contain an archive slug",
);
});
});
});
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"compilerOptions": {
"outDir": "build"
"outDir": "build",
"isolatedModules": true
},
"include": ["src"]
}