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
1 change: 1 addition & 0 deletions .claude/metrics/review-metrics.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@
{"pr":404,"issues":[359],"epic":4,"type":"feat","mergedAt":"2026-03-03T11:15:00Z","filesChanged":2,"linesChanged":94,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
{"pr":405,"issues":[394],"epic":4,"type":"feat","mergedAt":"2026-03-03T12:05:00Z","filesChanged":12,"linesChanged":974,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":1},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"request-changes","findings":{"critical":0,"high":1,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":2}],"totalFindings":{"critical":0,"high":1,"medium":0,"low":1,"informational":1}}
{"pr":410,"issues":[407,408,409],"epic":null,"type":"fix","mergedAt":"2026-03-03T00:00:00Z","filesChanged":20,"linesChanged":566,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":0},"round":1},{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":1,"informational":0}}
{"pr":412,"issues":[411],"epic":null,"type":"feat","mergedAt":"2026-03-03T00:00:00Z","filesChanged":9,"linesChanged":255,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":1},"round":1},{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":1,"informational":1}}
21 changes: 11 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,16 +389,17 @@ Hand-written SQL files in `server/src/db/migrations/` with a numeric prefix (e.g

### Environment Variables

| Variable | Default | Description |
| --------------------- | -------------------------- | --------------------------------------------- |
| `PORT` | `3000` | Server port |
| `HOST` | `0.0.0.0` | Server bind address |
| `DATABASE_URL` | `/app/data/cornerstone.db` | SQLite database path |
| `LOG_LEVEL` | `info` | Log level (trace/debug/info/warn/error/fatal) |
| `NODE_ENV` | `production` | Environment |
| `CLIENT_DEV_PORT` | `5173` | Webpack dev server port (development only) |
| `PAPERLESS_URL` | (none) | Paperless-ngx instance base URL |
| `PAPERLESS_API_TOKEN` | (none) | Paperless-ngx API authentication token |
| Variable | Default | Description |
| ------------------------ | -------------------------- | ----------------------------------------------------------------------------------- |
| `PORT` | `3000` | Server port |
| `HOST` | `0.0.0.0` | Server bind address |
| `DATABASE_URL` | `/app/data/cornerstone.db` | SQLite database path |
| `LOG_LEVEL` | `info` | Log level (trace/debug/info/warn/error/fatal) |
| `NODE_ENV` | `production` | Environment |
| `CLIENT_DEV_PORT` | `5173` | Webpack dev server port (development only) |
| `PAPERLESS_URL` | (none) | Paperless-ngx instance base URL |
| `PAPERLESS_API_TOKEN` | (none) | Paperless-ngx API authentication token |
| `PAPERLESS_EXTERNAL_URL` | (none) | Browser-facing URL for Paperless-ngx links (falls back to `PAPERLESS_URL` if unset) |

Production images use Docker Hardened Images (DHI). See `Dockerfile` and `docker-compose.yml` for build/deploy details.

Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ services:
- .env
# Paperless-ngx integration (optional — add to your .env file):
# environment:
# - PAPERLESS_URL=http://paperless:8000
# - PAPERLESS_URL=http://paperless:8000 # Internal URL used by the server for API calls
# - PAPERLESS_API_TOKEN=your-paperless-api-token
# - PAPERLESS_EXTERNAL_URL=https://paperless.example.com # Optional: browser-accessible URL for "View in Paperless-ngx" links
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"]
Expand Down
3 changes: 2 additions & 1 deletion docs/src/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ The document integration is automatically enabled when both `PAPERLESS_URL` and

| Variable | Default | Description |
|----------|---------|-------------|
| `PAPERLESS_URL` | -- | Base URL of your Paperless-ngx instance (e.g., `https://paperless.example.com`) |
| `PAPERLESS_URL` | -- | Base URL of your Paperless-ngx instance used by the server for API calls (e.g., `http://paperless:8000` in Docker) |
| `PAPERLESS_API_TOKEN` | -- | API authentication token from Paperless-ngx |
| `PAPERLESS_EXTERNAL_URL` | -- | Browser-facing URL for Paperless-ngx links (e.g., `https://paperless.example.com`). If unset, falls back to `PAPERLESS_URL`. |

For detailed setup instructions, see [Documents Setup](/guides/documents/setup).
48 changes: 46 additions & 2 deletions docs/src/guides/documents/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ To enable the document integration, configure two environment variables and rest

| Variable | Required | Description |
|----------|----------|-------------|
| `PAPERLESS_URL` | Yes | Base URL of your Paperless-ngx instance (e.g., `https://paperless.example.com`) |
| `PAPERLESS_URL` | Yes | Base URL of your Paperless-ngx instance used by the server for API calls (e.g., `http://paperless:8000` in Docker) |
| `PAPERLESS_API_TOKEN` | Yes | API authentication token from Paperless-ngx |
| `PAPERLESS_EXTERNAL_URL` | No | Browser-facing URL for Cornerstone browser links to Paperless-ngx (e.g., `https://paperless.example.com`). If unset, falls back to `PAPERLESS_URL`. |

Both variables must be set for the integration to activate. If either is missing, Cornerstone will show a "Not Configured" message on the Documents page and in the document linking sections.
`PAPERLESS_URL` and `PAPERLESS_API_TOKEN` must be set for the integration to activate. If either is missing, Cornerstone will show a "Not Configured" message on the Documents page and in the document linking sections.

### Getting Your API Token

Expand All @@ -32,6 +33,21 @@ docker run -d \
-v cornerstone-data:/app/data \
-e PAPERLESS_URL=https://paperless.example.com \
-e PAPERLESS_API_TOKEN=your-api-token-here \
-e PAPERLESS_EXTERNAL_URL=https://paperless.example.com \
steilerdev/cornerstone:latest
```

If Cornerstone and Paperless-ngx are on the same Docker network, you can use separate URLs:

```bash
docker run -d \
--name cornerstone \
-p 3000:3000 \
-v cornerstone-data:/app/data \
--network my-network \
-e PAPERLESS_URL=http://paperless:8000 \
-e PAPERLESS_API_TOKEN=your-api-token-here \
-e PAPERLESS_EXTERNAL_URL=https://paperless.example.com \
steilerdev/cornerstone:latest
```

Expand All @@ -50,13 +66,41 @@ services:
environment:
PAPERLESS_URL: https://paperless.example.com
PAPERLESS_API_TOKEN: your-api-token-here
PAPERLESS_EXTERNAL_URL: https://paperless.example.com
```

Or set them in your `.env` file:

```env
PAPERLESS_URL=https://paperless.example.com
PAPERLESS_API_TOKEN=your-api-token-here
PAPERLESS_EXTERNAL_URL=https://paperless.example.com
```

For a Docker network setup where Cornerstone and Paperless-ngx are connected internally:

```yaml
services:
cornerstone:
image: steilerdev/cornerstone:latest
ports:
- '3000:3000'
volumes:
- cornerstone-data:/app/data
networks:
- internal
environment:
PAPERLESS_URL: http://paperless:8000
PAPERLESS_API_TOKEN: your-api-token-here
PAPERLESS_EXTERNAL_URL: https://paperless.example.com

paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
networks:
- internal

networks:
internal:
```

## Network Requirements
Expand Down
81 changes: 81 additions & 0 deletions server/src/plugins/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => {
oidcRedirectUri: undefined,
oidcEnabled: false,
paperlessUrl: undefined,
paperlessExternalUrl: undefined,
paperlessApiToken: undefined,
paperlessEnabled: false,
});
Expand Down Expand Up @@ -55,6 +56,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => {
oidcRedirectUri: undefined,
oidcEnabled: false,
paperlessUrl: undefined,
paperlessExternalUrl: undefined,
paperlessApiToken: undefined,
paperlessEnabled: false,
});
Expand Down Expand Up @@ -86,6 +88,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => {
oidcRedirectUri: undefined,
oidcEnabled: false,
paperlessUrl: undefined,
paperlessExternalUrl: undefined,
paperlessApiToken: undefined,
paperlessEnabled: false,
});
Expand All @@ -112,6 +115,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => {
oidcRedirectUri: undefined,
oidcEnabled: false,
paperlessUrl: undefined,
paperlessExternalUrl: undefined,
paperlessApiToken: undefined,
paperlessEnabled: false,
});
Expand Down Expand Up @@ -365,6 +369,83 @@ describe('Configuration Module - loadConfig() Pure Function', () => {
});
});

describe('PAPERLESS_EXTERNAL_URL Configuration', () => {
it('PAPERLESS_EXTERNAL_URL not set → paperlessExternalUrl is undefined', () => {
const config = loadConfig({});
expect(config.paperlessExternalUrl).toBeUndefined();
});

it('valid https:// URL → paperlessExternalUrl equals that URL', () => {
const config = loadConfig({
PAPERLESS_EXTERNAL_URL: 'https://paperless.example.com',
});

expect(config.paperlessExternalUrl).toBe('https://paperless.example.com');
});

it('valid http:// URL → accepted', () => {
const config = loadConfig({
PAPERLESS_EXTERNAL_URL: 'http://paperless.internal:8000',
});

expect(config.paperlessExternalUrl).toBe('http://paperless.internal:8000');
});

it('file:// scheme → throws error containing PAPERLESS_EXTERNAL_URL must use http or https scheme, got: file', () => {
expect(() =>
loadConfig({
PAPERLESS_EXTERNAL_URL: 'file:///etc/passwd',
}),
).toThrow('PAPERLESS_EXTERNAL_URL must use http or https scheme, got: file');
});

it('ftp:// scheme → throws error containing PAPERLESS_EXTERNAL_URL must use http or https scheme, got: ftp', () => {
expect(() =>
loadConfig({
PAPERLESS_EXTERNAL_URL: 'ftp://host/resource',
}),
).toThrow('PAPERLESS_EXTERNAL_URL must use http or https scheme, got: ftp');
});

it('invalid URL string → throws error containing PAPERLESS_EXTERNAL_URL must be a valid URL', () => {
expect(() =>
loadConfig({
PAPERLESS_EXTERNAL_URL: 'not-a-url',
}),
).toThrow('PAPERLESS_EXTERNAL_URL must be a valid URL, got: not-a-url');
});

it('empty string → treated as undefined, paperlessExternalUrl is undefined', () => {
const config = loadConfig({
PAPERLESS_EXTERNAL_URL: '',
});

expect(config.paperlessExternalUrl).toBeUndefined();
});

it('external URL set without PAPERLESS_URL/PAPERLESS_API_TOKEN → paperlessEnabled is false, but paperlessExternalUrl is set', () => {
const config = loadConfig({
PAPERLESS_EXTERNAL_URL: 'https://external.example.com',
});

expect(config.paperlessEnabled).toBe(false);
expect(config.paperlessExternalUrl).toBe('https://external.example.com');
});

it('all three paperless vars set → paperlessEnabled is true, both URLs set', () => {
const config = loadConfig({
PAPERLESS_URL: 'http://paperless:8000',
PAPERLESS_EXTERNAL_URL: 'https://external.example.com',
PAPERLESS_API_TOKEN: 'test-token',
});

expect(config.paperlessEnabled).toBe(true);
expect(config.paperlessUrl).toBe('http://paperless:8000');
expect(config.paperlessExternalUrl).toBe('https://external.example.com');
expect(config.paperlessApiToken).toBe('test-token');
});
});

describe('Scenario 6: Collect All Validation Errors', () => {
it('reports multiple bad values in a single error', () => {
expect(() =>
Expand Down
21 changes: 21 additions & 0 deletions server/src/plugins/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface AppConfig {
oidcRedirectUri?: string;
oidcEnabled: boolean;
paperlessUrl?: string;
paperlessExternalUrl?: string;
paperlessApiToken?: string;
paperlessEnabled: boolean;
}
Expand Down Expand Up @@ -126,6 +127,25 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
}
}

// Paperless-ngx external URL (optional, used for browser-facing links)
const paperlessExternalUrlRaw = getValue('PAPERLESS_EXTERNAL_URL');
let paperlessExternalUrl: string | undefined = undefined;
if (paperlessExternalUrlRaw) {
try {
const parsed = new URL(paperlessExternalUrlRaw);
const allowedSchemes = ['http:', 'https:'];
if (!allowedSchemes.includes(parsed.protocol)) {
errors.push(
`PAPERLESS_EXTERNAL_URL must use http or https scheme, got: ${parsed.protocol.replace(':', '')}`,
);
} else {
paperlessExternalUrl = paperlessExternalUrlRaw;
}
} catch {
errors.push(`PAPERLESS_EXTERNAL_URL must be a valid URL, got: ${paperlessExternalUrlRaw}`);
}
}

// Paperless-ngx is enabled when both URL and API token are set
const paperlessEnabled = !!(paperlessUrl && paperlessApiToken);

Expand All @@ -149,6 +169,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
oidcRedirectUri,
oidcEnabled,
paperlessUrl,
paperlessExternalUrl,
paperlessApiToken,
paperlessEnabled,
};
Expand Down
67 changes: 67 additions & 0 deletions server/src/routes/paperless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ describe('Paperless Routes', () => {
await app.close();
}

delete process.env.PAPERLESS_EXTERNAL_URL;
process.env = originalEnv;

try {
Expand Down Expand Up @@ -146,6 +147,17 @@ describe('Paperless Routes', () => {
app = await buildApp();
}

/**
* Helper: Re-build the app with Paperless and External URL configured
*/
async function rebuildAppWithPaperlessAndExternalUrl(): Promise<void> {
await app.close();
process.env.PAPERLESS_URL = 'http://paperless:8000';
process.env.PAPERLESS_API_TOKEN = 'test-token';
process.env.PAPERLESS_EXTERNAL_URL = 'https://external.example.com';
app = await buildApp();
}

// ─── GET /api/paperless/status ─────────────────────────────────────────────

describe('GET /api/paperless/status', () => {
Expand Down Expand Up @@ -213,6 +225,61 @@ describe('Paperless Routes', () => {
expect(body.error).toContain('ECONNREFUSED');
expect(body.paperlessUrl).toBe('http://paperless:8000');
});

it('when PAPERLESS_EXTERNAL_URL is set and Paperless is reachable → paperlessUrl equals external URL', async () => {
await rebuildAppWithPaperlessAndExternalUrl();
const { cookie } = await createUserWithSession();

mockFetch.mockResolvedValueOnce(mockJsonResponse({ count: 10 }));

const response = await app.inject({
method: 'GET',
url: '/api/paperless/status',
headers: { cookie },
});

expect(response.statusCode).toBe(200);
const body = response.json<PaperlessStatusResponse>();
expect(body.configured).toBe(true);
expect(body.reachable).toBe(true);
expect(body.paperlessUrl).toBe('https://external.example.com');
});

it('when PAPERLESS_EXTERNAL_URL is set and Paperless is unreachable → paperlessUrl still equals external URL', async () => {
await rebuildAppWithPaperlessAndExternalUrl();
const { cookie } = await createUserWithSession();

mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));

const response = await app.inject({
method: 'GET',
url: '/api/paperless/status',
headers: { cookie },
});

expect(response.statusCode).toBe(200);
const body = response.json<PaperlessStatusResponse>();
expect(body.configured).toBe(true);
expect(body.reachable).toBe(false);
expect(body.paperlessUrl).toBe('https://external.example.com');
});

it('backward compatibility: no external URL → paperlessUrl shows internal URL', async () => {
await rebuildAppWithPaperless();
const { cookie } = await createUserWithSession();

mockFetch.mockResolvedValueOnce(mockJsonResponse({ count: 10 }));

const response = await app.inject({
method: 'GET',
url: '/api/paperless/status',
headers: { cookie },
});

expect(response.statusCode).toBe(200);
const body = response.json<PaperlessStatusResponse>();
expect(body.paperlessUrl).toBe('http://paperless:8000');
});
});

// ─── GET /api/paperless/documents ─────────────────────────────────────────
Expand Down
5 changes: 4 additions & 1 deletion server/src/routes/paperless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ export default async function paperlessRoutes(fastify: FastifyInstance) {
fastify.config.paperlessUrl!,
fastify.config.paperlessApiToken!,
);
return reply.status(200).send({ ...status, paperlessUrl: fastify.config.paperlessUrl ?? null });
return reply.status(200).send({
...status,
paperlessUrl: fastify.config.paperlessExternalUrl ?? fastify.config.paperlessUrl ?? null,
});
});

/**
Expand Down
Loading
Loading