Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/demo-mode-and-site.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opentask": minor
---

Demo mode (`OPENTASK_DEMO_MODE`) — run OpenTask as a public sandbox. The instance publishes its seeded credentials on `/api/v1/info` so the login page offers one-click access, shows a reset countdown banner, hides the Settings pages it disables, and refuses every write that could reach out of the container, fill the disk, or lock the next visitor out (notification channels, importer, attachments, rambles, backups, tokens, push, iCal token, integrations, and account mutations). Everything else — tasks, projects, filters, search, boards, productivity — behaves exactly as it does on a private instance. `deploy/demo/` ships a supervisor that wipes, re-seeds, and restarts on a timer, so a disposable demo is one `docker compose up`.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ apps/web/e2e/.auth/
apps/web/.auth/
apps/web/test-results/
apps/web/playwright-report/
.vercel
12 changes: 12 additions & 0 deletions .vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Keep the upload to source only — the CLI otherwise walks the whole workspace, including
# every node_modules tree, and the deploy stalls before it ever reaches Vercel.
node_modules
**/node_modules
.git
.next
**/.next
apps/web/dist
apps/desktop/src-tauri/target
apps/desktop/src-tauri/gen
**/*.tsbuildinfo
data
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@
<p align="center"><sub>Formerly <strong>OpenDoist</strong> — renamed in v0.4.0; every legacy surface (env vars, tokens, backups, data files) keeps working. See <a href="#migrating-from-opendoist">Migrating from OpenDoist</a>.</sub></p>

<p align="center">
<a href="https://github.com/pranav-karra-3301/opentask/actions/workflows/ci.yml"><img src="https://github.com/pranav-karra-3301/opentask/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/pranav-karra-3301/opentask/releases"><img src="https://img.shields.io/github/v/release/pranav-karra-3301/opentask?color=4c7a45&label=release" alt="Latest release"></a>
<a href="https://github.com/pranav-karra-3301/opentask/pkgs/container/opentask"><img src="https://img.shields.io/badge/ghcr.io-opentask-1f60c2" alt="GHCR image"></a>
<a href="https://github.com/junkdrawerlab/opentask/actions/workflows/ci.yml"><img src="https://github.com/junkdrawerlab/opentask/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/junkdrawerlab/opentask/releases"><img src="https://img.shields.io/github/v/release/junkdrawerlab/opentask?color=4c7a45&label=release" alt="Latest release"></a>
<a href="https://github.com/users/Pranav-Karra-3301/packages/container/package/opentask"><img src="https://img.shields.io/badge/ghcr.io-opentask-1f60c2" alt="GHCR image"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-4c7a45" alt="License: AGPL-3.0"></a>
<img src="https://img.shields.io/badge/node-%E2%89%A5%2022-4c7a45" alt="Node >= 22">
</p>

<p align="center">
<a href="https://opentask.pranavkarra.me"><strong>Website</strong></a> ·
<a href="https://tryopentask.pranavkarra.me"><strong>Live demo</strong></a> ·
<a href="https://opentask.pranavkarra.me/docs"><strong>Docs</strong></a> ·
<a href="https://opentask.pranavkarra.me/changelog"><strong>Changelog</strong></a>
</p>

<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/hero-dark.png">
Expand Down Expand Up @@ -170,7 +177,7 @@ Full docs live in [`docs/`](docs/):
Requires Node ≥ 22 and pnpm 10.

```sh
git clone https://github.com/pranav-karra-3301/opentask.git
git clone https://github.com/junkdrawerlab/opentask.git
cd opentask
pnpm install
pnpm verify # lint + typecheck + test + build, everything CI runs
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,15 @@ export const InfoDtoSchema = z.object({
update: z
.object({ available: z.boolean(), latestVersion: z.string(), url: z.string() })
.nullable(),
/** Public-sandbox facts; null on every normal instance. Credentials are published on
* purpose so the login page can prefill them — see demo.ts. */
demo_mode: z
.object({
enabled: z.literal(true),
resets_at: z.string(),
reset_seconds: z.number(),
email: z.string(),
password: z.string(),
})
.nullable(),
})
24 changes: 24 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { backupsRouter } from './backups/routes'
import type { Config } from './config'
import { user } from './db/auth-schema'
import type { Db } from './db/db'
import { DEMO_EMAIL, DEMO_PASSWORD, demoGuard } from './demo'
import type { EventBus } from './events/bus'
import { exportRouter } from './export/routes'
import { icalFeedRoutes, icalTokenRoutes } from './ical/routes'
Expand Down Expand Up @@ -76,6 +77,12 @@ function apiKeyScope(permissions: unknown): 'read' | 'read_write' {
}

export function createApp(deps: AppDeps): OpenAPIHono<AppEnv> {
// Demo countdown anchor. The reset is implemented as "kill the server, wipe /data,
// re-seed, restart" (deploy/demo/entrypoint.sh), so this process's own lifetime IS the
// time-to-reset — no scheduler, no persisted state, and it survives an OOM-kill restart
// because the replacement process re-anchors on its own boot.
const bootedAt = Date.now()

// 1. Root app: zod validation failures become RFC 9457 problem JSON.
const app = new OpenAPIHono<AppEnv>({
defaultHook: (result, c) => {
Expand Down Expand Up @@ -122,6 +129,11 @@ export function createApp(deps: AppDeps): OpenAPIHono<AppEnv> {
// answers 503 problem JSON. /api/health stays reachable — its route is registered above.
app.use('*', maintenanceGuard)

// 3c. Demo write guard — a no-op unless OPENTASK_DEMO_MODE is set. Registered above the
// better-auth handler so one middleware covers both /api/auth account mutations and the
// abusable /api/v1 surfaces (see demo-guard.ts for the per-surface rationale).
app.use('*', demoGuard(deps.config))

// 4. better-auth endpoints.
app.on(['GET', 'POST'], '/api/auth/*', (c) => deps.auth.handler(c.req.raw))

Expand Down Expand Up @@ -177,6 +189,18 @@ export function createApp(deps: AppDeps): OpenAPIHono<AppEnv> {
url: updateState.url,
}
: null,
// Public sandbox facts (null on every normal instance). The credentials are published
// deliberately: the demo is meant to be walked into, and the login page prefills from
// here so nobody has to copy-paste them off the marketing site.
demo_mode: deps.config.demoMode
? {
enabled: true,
resets_at: new Date(bootedAt + deps.config.demoResetSeconds * 1000).toISOString(),
reset_seconds: deps.config.demoResetSeconds,
email: DEMO_EMAIL,
password: DEMO_PASSWORD,
}
: null,
})
})

Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export const ConfigSchema = z.object({
webDistDir: z.string().nullable(),
allowRegistration: z.boolean(),
disableUpdateCheck: z.boolean(),
/** Public-sandbox mode: blocks abusable writes, hides account/integration surfaces. */
demoMode: z.boolean(),
/** Seconds between demo wipes — surfaced on /info so the UI can count down. */
demoResetSeconds: z.number().int().min(60),
logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']),
trustProxy: z.boolean(),
uploadMaxMb: z.number().int().min(1),
Expand Down Expand Up @@ -81,13 +85,17 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
apiKey: g(`${p}_API_KEY`) ?? null,
}
: null
const demoMode = bool(g('DEMO_MODE'), false)
return ConfigSchema.parse({
publicUrl: g('PUBLIC_URL') ?? null,
port: Number(g('PORT') ?? 7968),
dataDir: g('DATA_DIR') ?? '/data',
webDistDir: g('WEB_DIST') ?? null,
allowRegistration: bool(g('ALLOW_REGISTRATION'), false),
disableUpdateCheck: bool(g('DISABLE_UPDATE_CHECK'), false),
// A demo wipes itself on a timer, so an update banner is noise it can never act on.
disableUpdateCheck: demoMode || bool(g('DISABLE_UPDATE_CHECK'), false),
demoMode,
demoResetSeconds: Number(g('DEMO_RESET_SECONDS') ?? 1800),
logLevel: g('LOG_LEVEL') ?? 'info',
trustProxy: bool(g('TRUST_PROXY'), false),
uploadMaxMb: Number(g('UPLOAD_MAX_MB') ?? 25),
Expand Down
183 changes: 183 additions & 0 deletions apps/server/src/demo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { afterEach, describe, expect, it, test } from 'vitest'
import { InfoDtoSchema } from './api/schemas'
import { loadConfig } from './config'
import { DEMO_BLOCKED_API_PREFIXES, DEMO_EMAIL, DEMO_PASSWORD, demoBlockReason } from './demo'
import { createTestApp, json, type TestApp } from './test/helpers'

let apps: TestApp[] = []
async function make(opts?: Parameters<typeof createTestApp>[0]): Promise<TestApp> {
const t = await createTestApp(opts)
apps.push(t)
return t
}
/** A signed-up instance with the demo guard active. */
const demo = () => make({ env: { OPENTASK_DEMO_MODE: 'true' } })

afterEach(() => {
for (const t of apps) t.close()
apps = []
})

describe('config', () => {
test('demo mode is off by default', () => {
const c = loadConfig({})
expect(c.demoMode).toBe(false)
expect(c.demoResetSeconds).toBe(1800)
})

test('OPENTASK_DEMO_MODE also forces the update check off', () => {
const c = loadConfig({ OPENTASK_DEMO_MODE: 'true' })
expect(c.demoMode).toBe(true)
expect(c.disableUpdateCheck).toBe(true)
})

test('OPENTASK_DEMO_RESET_SECONDS overrides the interval', () => {
expect(loadConfig({ OPENTASK_DEMO_RESET_SECONDS: '90' }).demoResetSeconds).toBe(90)
})

test('a sub-minute reset interval fails validation', () => {
expect(() => loadConfig({ OPENTASK_DEMO_RESET_SECONDS: '5' })).toThrow()
})

test('the legacy OPENDOIST_ spelling still works', () => {
expect(loadConfig({ OPENDOIST_DEMO_MODE: 'yes' }).demoMode).toBe(true)
})
})

describe('demoBlockReason', () => {
test('reads are never blocked, including on guarded prefixes', () => {
for (const { prefix } of DEMO_BLOCKED_API_PREFIXES) {
expect(demoBlockReason('GET', `/api/v1${prefix}`), prefix).toBeNull()
}
})

test('every guarded prefix blocks writes, including subpaths', () => {
for (const { prefix } of DEMO_BLOCKED_API_PREFIXES) {
expect(demoBlockReason('POST', `/api/v1${prefix}`), prefix).not.toBeNull()
expect(demoBlockReason('DELETE', `/api/v1${prefix}/abc123`), prefix).not.toBeNull()
}
})

test('the core task surfaces stay writable', () => {
for (const path of [
'/api/v1/tasks',
'/api/v1/tasks/quick',
'/api/v1/tasks/abc/close',
'/api/v1/projects',
'/api/v1/sections',
'/api/v1/labels',
'/api/v1/filters',
'/api/v1/comments',
'/api/v1/user/settings',
'/api/v1/reminders',
]) {
expect(demoBlockReason('POST', path), path).toBeNull()
}
})

test('PATCH /user is blocked exactly, without catching /user/settings', () => {
// The display name is global chrome; the settings document is how you try the product.
expect(demoBlockReason('PATCH', '/api/v1/user')).not.toBeNull()
expect(demoBlockReason('PATCH', '/api/v1/user/settings')).toBeNull()
expect(demoBlockReason('GET', '/api/v1/user')).toBeNull()
})

test('a prefix match is on a path segment, not a substring', () => {
// `/tokens` is blocked; `/tokensomething` must not be caught by it.
expect(demoBlockReason('POST', '/api/v1/tokensomething')).toBeNull()
// `/push-subscriptions` is blocked but the public VAPID key read is a different path.
expect(demoBlockReason('GET', '/api/v1/push/vapid-public-key')).toBeNull()
})

test('account and credential mutations are blocked at any method', () => {
for (const path of [
'/api/auth/change-password',
'/api/auth/change-email',
'/api/auth/update-user',
'/api/auth/delete-user',
'/api/auth/two-factor/enable',
'/api/auth/api-key/create',
]) {
expect(demoBlockReason('POST', path), path).not.toBeNull()
}
})

test('signing in and out stay open — the demo is unusable otherwise', () => {
expect(demoBlockReason('POST', '/api/auth/sign-in/email')).toBeNull()
expect(demoBlockReason('POST', '/api/auth/sign-out')).toBeNull()
expect(demoBlockReason('GET', '/api/auth/get-session')).toBeNull()
})
})

describe('/api/v1/info', () => {
it('reports demo_mode: null on a normal instance', async () => {
const t = await make()
const info = InfoDtoSchema.parse(await json<unknown>(await t.request('/api/v1/info')))
expect(info.demo_mode).toBeNull()
})

it('publishes the credentials and a future reset time in demo mode', async () => {
const t = await demo()
const info = InfoDtoSchema.parse(await json<unknown>(await t.request('/api/v1/info')))
expect(info.demo_mode).not.toBeNull()
expect(info.demo_mode?.email).toBe(DEMO_EMAIL)
expect(info.demo_mode?.password).toBe(DEMO_PASSWORD)
expect(info.demo_mode?.reset_seconds).toBe(1800)
const resetsAt = Date.parse(info.demo_mode?.resets_at ?? '')
expect(resetsAt).toBeGreaterThan(Date.now())
expect(resetsAt).toBeLessThanOrEqual(Date.now() + 1800 * 1000)
})

it('anchors the countdown at boot, so it does not drift between requests', async () => {
const t = await demo()
const first = InfoDtoSchema.parse(await json<unknown>(await t.request('/api/v1/info')))
await new Promise((r) => setTimeout(r, 25))
const second = InfoDtoSchema.parse(await json<unknown>(await t.request('/api/v1/info')))
expect(second.demo_mode?.resets_at).toBe(first.demo_mode?.resets_at)
})
})

describe('the guard over a real app', () => {
it('refuses guarded writes with a demo-mode problem document', async () => {
const t = await demo()
const res = await t.post('/api/v1/channels', { type: 'webhook', url: 'https://evil.test' })
expect(res.status).toBe(403)
expect(res.headers.get('content-type')).toContain('application/problem+json')
const body = await json<{ type: string; title: string; detail: string }>(res)
expect(body.title).toBe('demo mode')
expect(body.type).toContain('demo-mode')
expect(body.detail).toContain('public demo')
})

it('still lets a visitor create a task — the whole point of the demo', async () => {
const t = await demo()
const res = await t.post('/api/v1/tasks/quick', { text: 'Try OpenTask tomorrow 9am p1' })
expect(res.status).toBe(201)
})

it('leaves those same writes working on a normal instance', async () => {
const t = await make()
const res = await t.post('/api/v1/channels', { type: 'webhook', url: 'https://evil.test' })
expect(res.status).not.toBe(403)
})

it('blocks a password change that would lock out the next visitor', async () => {
const t = await demo()
const res = await t.request('/api/auth/change-password', {
method: 'POST',
headers: { cookie: t.cookie, 'content-type': 'application/json' },
body: JSON.stringify({ currentPassword: 'password1234', newPassword: 'hijacked12345' }),
})
expect(res.status).toBe(403)
})

it('still allows sign-in, so the published credentials work', async () => {
const t = await demo()
const res = await t.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'test@example.com', password: 'password1234' }),
})
expect(res.status).toBe(200)
})
})
Loading
Loading