diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08e51c3..6bc829a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,15 @@ jobs: - name: Build web run: pnpm --filter @flow/web build + - name: Build the static demo + run: pnpm --filter @flow/web build:demo + + # The committed root wrangler.jsonc is what the Deploy to Cloudflare + # button deploys; checked before the step below creates + # apps/api/wrangler.jsonc, so the one-click path is what runs. + - name: One-click config dry-run deploy + run: node scripts/deploy-worker.mjs --dry-run + - name: Wrangler dry-run deploy working-directory: apps/api run: | diff --git a/.gitignore b/.gitignore index 23069ce..8130922 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +dist-demo/ coverage/ # Cloudflare / wrangler diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..78fb749 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +## 0.2.0 (unreleased) + +The first tagged release. It covers everything since the initial publish in July 2026. Migrations `core-0009` through `core-0014` run automatically the first time the Durable Object starts on the new code, and every new column is nullable or defaulted, so existing workspaces need no manual steps. + +### Try it + +- **Deploy to Cloudflare button.** The committed root `wrangler.jsonc` deploys Flow to a `*.workers.dev` address with its Durable Object, R2 bucket and queues. Until Cloudflare Access is set up, the app shows the remaining steps instead of failing, and the public `GET /api/setup` reports whether sign-in is configured. +- **Read-only demo build.** `pnpm build:demo` builds the SPA as a static site on sample data that needs no server, and `pnpm deploy:demo` hosts it as an assets-only Worker. `?embed` hides the demo badge. + +### Added + +- **Agent handoffs** (`core-0013`). One person's agent files a request for a teammate's agent as a task with a state (requested → working / needs_input → done / declined). It's available over MCP (`flow_request_handoff`, `flow_list_handoffs`, `flow_reply_handoff`) and REST, and the task panel shows a handoff strip. Comments now record `via`, so agent-written replies are labelled. +- **Email intake** (`core-0012`). Each list can take tickets at `@EMAIL_INTAKE_DOMAIN` from allowlisted senders: + - replies in the thread become comments + - sender authenticity comes from Cloudflare's own authentication results + - authenticated senders can get an optional acknowledgement + - rate limits apply per sender and per intake + - it's managed in Settings → Inbound +- **Email replies** (`core-0014`). A comment posted with `emailToRequester` is emailed to the sender who opened the ticket, in their thread, and the task records "Emailed to …". It's API-only, for keys an owner or admin allowed (`PATCH /api/api-keys/:id`), and never available over MCP. +- **`set_due_date` automation action**, with a time-of-day cutoff in a given time zone and an optional business-days rule. +- **Subtask notes** (`core-0009`): a plain-text note on each subtask. +- **My Work** now includes your own subtasks, plus an "assigned out" rail for work you delegated. It's backed by assignment attribution (`core-0010`). +- **Clickable URL titles.** A task whose title is a link gets an open-link icon. +- **Backups**: + - outcomes are recorded (`core-0011`) + - `/api/health` reports staleness + - `GET`/`POST /api/admin/backup` shows status and runs a backup on demand + +### Fixed + +- **Daily backups never succeeded:** the runtime's internal `_cf_KV` table can't be read, and the backup job tried to export it. +- **Security:** `GET /ws` now checks the request `Origin` before accepting an Access-cookie handshake. +- **Security:** + - attachments are served as downloads unless they're safe raster images + - cookie-authenticated mutations must be same-origin +- **Security:** Message-ID and reply-subject parsing no longer slow down on crafted input (both were quadratic). +- An empty subtask `PATCH` no longer unchecks a completed subtask. +- The web store no longer inserts half-empty rows for update deltas about unknown ids. +- Moving work across spaces, or changing a role, resyncs the affected sockets. +- `status_changed` no longer fires on a cross-list move that keeps the status name. +- Side effects are fitted to the Queues 128KB message limit instead of being dropped. +- `automation_runs` and `audit` are pruned. +- The importer retries rows that failed instead of recording them as loaded. + +### Dependencies + +- wrangler 4.151, with patched sharp. +- source-map-js pinned to 1.2.2. +- Patched hono, the MCP SDK, prosemirror-view and proxy-addr. +- vitest 5. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e21729..f8f321a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,7 @@ points: via an extract → transform → load pipeline with checkpoints; an Asana or Trello extractor that emits the same intermediate shape reuses transform and load for free. -- **New MCP tools** — if an agent workflow you run needs a capability the 15 +- **New MCP tools** — if an agent workflow you run needs a capability the 18 existing tools don't cover, propose it in an issue; tool names live in `packages/shared/src/api.ts` and implementations in `apps/api/src/mcp/`. See [docs/AGENTS_AND_MCP.md](docs/AGENTS_AND_MCP.md). diff --git a/README.md b/README.md index 1dcded8..f173b29 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,19 @@ # Flow [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/joelborch/flow) **Agent-first project management you self-host on Cloudflare.** A drop-in replacement for the ClickUp/Asana/Linear slice most teams actually use — spaces, lists, boards, tasks, subtasks, comments, attachments, automations — built so that AI agents are first-class users, not an afterthought bolted onto a REST API. +**[Try the read-only demo](https://flow-demo.joel-a67.workers.dev)** (sample data, nothing to install), or deploy your own copy with the button above. + +

+ + + A Flow board: four status columns of task cards with due dates, subtask progress, tags and assignees + +

+ One Cloudflare Worker serves the SPA, the REST API, an MCP server, and WebSockets. One Durable Object holds the entire workspace in SQLite. The Cloudflare free tier covers a small team: Workers, one DO, R2 for attachments, Queues for side effects. Your realistic monthly bill is $0–5, against $7–19 per seat per month for the SaaS tools it replaces. ## Why it exists @@ -33,6 +43,17 @@ Task managers are slow, expensive per seat, and hostile to agents. Flow inverts - **ClickUp import** — a three-pass extract/transform/load pipeline with deterministic ids, so re-running is an upsert, not a duplicate workspace - **Dark mode, keyboard layer, instant loads** — Preact + Signals SPA, ~no dependencies, boot cache, early WebSocket open from an inline script in `index.html` + + + + + + + + + +
A task panel showing an agent handoff: who asked, what they want back, its state, and the descriptionMy Work: overdue, today, this week and no-date buckets, with an assigned-out rail
An agent handoff on a taskMy Work, with what you've assigned out
+ ## What it deliberately doesn't do Flow replaces the slice of ClickUp a small team actually uses. The rest is left out on purpose, because each omission removes a permanent complexity tax: @@ -70,7 +91,9 @@ Every mutation runs as one synchronous turn inside the DO: write rows, append on ## Quickstart -Prerequisites: Node 20+, [pnpm](https://pnpm.io), a Cloudflare account, `wrangler` (installed as a dev dependency). +**One click.** The [Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/joelborch/flow) button copies the repo into your GitHub account, creates the Durable Object, R2 bucket and queues, and deploys to a `*.workers.dev` address. Until sign-in is set up, Flow shows the remaining steps: create two Cloudflare Access applications, then set `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` and `OWNER_EMAIL` under the Worker's Settings → Variables ([details](docs/SELF_HOSTING.md#5-cloudflare-access)). The button deploys from the root `wrangler.jsonc`; for a custom domain and email, use the manual setup below. If the Cloudflare account already runs a Flow, rename the bucket and both queues on the button's setup page, because it preselects existing resources with the default names. + +**By hand.** Prerequisites: Node 20+, [pnpm](https://pnpm.io), a Cloudflare account, `wrangler` (installed as a dev dependency). ```bash git clone https://github.com/joelborch/flow.git @@ -141,7 +164,7 @@ packages/core The Workspace Durable Object: SQLite schema, mutation turns, aut ## Contributing -PRs welcome. Ground rules: TypeScript strict everywhere, `pnpm typecheck` and `pnpm test` must pass, no new runtime dependencies without a strong reason (the whole app currently runs on hono, zod, preact, and @preact/signals), and contract changes go through `packages/shared`. Good first contributions: an Asana or Trello extractor for the importer ([docs/IMPORTING.md](docs/IMPORTING.md#future-work-asana-and-trello) shows exactly where it plugs in), and @-mention parsing (the notification preference already exists and is waiting for an emitter). +PRs welcome. Ground rules: TypeScript strict everywhere, `pnpm typecheck` and `pnpm test` must pass, no new runtime dependencies without a strong reason (the whole app currently runs on hono, zod, preact, and @preact/signals), and contract changes go through `packages/shared`. Good first contributions: an Asana or Trello extractor for the importer ([docs/IMPORTING.md](docs/IMPORTING.md#future-work-asana-and-trello) shows exactly where it plugs in), and @-mention parsing (the notification preference already exists and is waiting for an emitter). [ROADMAP.md](ROADMAP.md) lists what's planned and what's deliberately out of scope. ## License diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..508033e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,38 @@ +# Roadmap + +No dates on purpose: Flow is maintained alongside other work, and a date would be a guess. Each item says where the need comes from, so it can be checked against the repo. Want to help? The items marked as good first issues are a good place to start, and anything under Later deserves an issue to discuss the design first. + +## Now + +- **Docs catch-up.** A few docs fell behind recent changes: the MCP tool count, the runtime dependency list, the core package README, and the automation template fields. Open as good first issues. +- **Importer catch-up sync.** A `load --resend` flag so a cutover can re-send rows that changed in ClickUp after the first load without duplicating attachments. A good first issue. +- **Tests for small pure helpers.** Fractional ordering, the importer's rate limiter, and the web app's date formatters. Open as good first issues. + +## Next + +- **@-mentions.** The `mention` notification preference already exists and defaults on, but nothing parses mentions or emits the event yet (`packages/shared/src/notifications.ts`). This needs a parser, a delta or side-effect carrying the mention, and the email template. +- **Member management in the UI.** Today, users beyond the first owner go in through the admin import endpoint (`POST /api/import/batch` with a `users` array, per `docs/SELF_HOSTING.md`). Inviting, deactivating and changing roles from Settings would remove the last routine curl from setup. +- **Asana and Trello importers.** The importer's extract/transform/load seams are already documented in `docs/IMPORTING.md`; a new source needs its own extractor and transform mapping and reuses id derivation, validation and load unchanged. +- **MCP directory listings.** An official MCP Registry entry first, since other directories ingest from it. The open question is how to list a server that every team hosts at its own URL. +- **Handoff state notifications.** Today a requester hears about a `needs_input` question through the ordinary comment email. A notification for the state change itself would make the hand-back explicit. +- **Streamed backups.** The daily backup builds the whole NDJSON dump in memory before gzipping it (`runBackup` in `packages/core/src/index.ts`). Streaming it would raise the workspace size at which backups stop fitting. + +## Later (open an issue to discuss first) + +- **OAuth for the MCP server.** Clients and directories increasingly expect OAuth; Flow only supports static bearer keys, minted per user. Any OAuth design has to keep the current property that a key acts as one real user with an audit trail. +- **A guest or read-only role.** The permission model is members plus private spaces. Some teams need to show a client a board without making them a member. +- **Better mobile.** The app is responsive web only. An installable PWA with notifications is the likely step before anything native. +- **A per-user resync floor.** The workspace-wide floor in `packages/core/src/sync-floor.ts` sends a spurious snapshot to clients whose view didn't change. That's cheap today; it matters only if workspaces get much larger. + +## Not planned + +These are left out by design, and the reasons are in `README.md` and `docs/COMPARISON.md`. Each would add complexity to every query, form and API shape, so issues asking for them will be closed with a pointer to this list: + +- Gantt charts, timelines and workload views +- Time tracking +- Custom fields +- Folders (spaces contain lists) +- Multiple assignees per task +- Docs, whiteboards, chat and goals +- More than one workspace per deployment +- A runtime other than Cloudflare Workers and Durable Objects diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 90534e9..5bdae39 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -18,17 +18,35 @@ import { hashToken, looksLikeApiToken, parseBearer } from "./tokens.js"; * its id lands in the audit trail. * * Unauthenticated requests are rejected on /api/* and /mcp. The only exceptions - * are /api/health (liveness) and /api/inbound/* (authenticated per-list by the - * list's own inboundToken, inside the route). + * are /api/health (liveness), /api/setup (whether sign-in is configured, a + * single word), and /api/inbound/* (authenticated per-list by the list's own + * inboundToken, inside the route). */ /** Paths that skip this middleware entirely. */ export function isPublicPath(pathname: string): boolean { - if (pathname === "/api/health") return true; + if (pathname === "/api/health" || pathname === "/api/setup") return true; if (pathname === "/api/inbound" || pathname.startsWith("/api/inbound/")) return true; return false; } +/** + * How people sign in to this deployment, for the public GET /api/setup. A + * fresh deploy (the Deploy to Cloudflare button, or wrangler.example.jsonc + * copied as-is) has no Access AUD yet, so every request 401s; reporting + * "unconfigured" lets the web app show the setup steps instead of sending the + * browser to an Access login page that doesn't exist on this hostname. + */ +export function signInMode( + env: Pick +): "access" | "dev" | "unconfigured" { + if (env.DEV_NO_AUTH === "true") return "dev"; + const aud = (env.ACCESS_AUD ?? "").trim(); + const team = (env.ACCESS_TEAM_DOMAIN ?? "").trim().toLowerCase(); + const teamSet = team.endsWith(".cloudflareaccess.com") && team !== "your-team.cloudflareaccess.com"; + return /^[0-9a-f]{64}$/i.test(aud) && teamSet ? "access" : "unconfigured"; +} + function actorForUi(user: User): Actor { return { userId: user.id, via: "ui", apiKeyId: null, automationRuleId: null }; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6aad5d8..c721d18 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,7 @@ /// import { Hono } from "hono"; import { Workspace } from "@flow/core"; -import { authMiddleware } from "./auth.js"; +import { authMiddleware, signInMode } from "./auth.js"; import { cachedBackupHealth } from "./do.js"; import { handleEmail } from "./email-intake.js"; import { onError, notFoundHandler } from "./errors.js"; @@ -49,9 +49,14 @@ app.get("/api/health", async (c) => { } }); +// Whether sign-in is configured yet: "access", "dev" (DEV_NO_AUTH) or +// "unconfigured". Public and a single word, so the web app can ask before it +// redirects a 401 to Access (see signInMode in auth.ts). +app.get("/api/setup", (c) => c.json({ signIn: signInMode(c.env) })); + /** * Auth gate for everything that is not the SPA. `authMiddleware` lets - * /api/health and /api/inbound/* through (the latter authenticates per-list + * /api/health, /api/setup and /api/inbound/* through (the latter authenticates per-list * against the list's own token) and rejects everything else without a valid * Cloudflare Access JWT or `flow_` bearer token. */ diff --git a/apps/api/src/mcp/index.ts b/apps/api/src/mcp/index.ts index db40f26..dd716ef 100644 --- a/apps/api/src/mcp/index.ts +++ b/apps/api/src/mcp/index.ts @@ -28,7 +28,7 @@ import { ToolContext } from "./context.js"; import { registerFlowTools } from "./tools.js"; const SERVER_NAME = "flow"; -const SERVER_VERSION = "0.1.0"; +const SERVER_VERSION = "0.2.0"; /** * Read by the client before it has called anything, and often truncated — some diff --git a/apps/api/src/setup.test.ts b/apps/api/src/setup.test.ts new file mode 100644 index 0000000..aa3a7be --- /dev/null +++ b/apps/api/src/setup.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { isPublicPath, signInMode } from "./auth.js"; +import type { Env } from "./env.js"; + +// GET /api/setup tells the web app whether sign-in is configured, so a fresh +// deploy shows its setup steps instead of redirecting to an Access login that +// doesn't exist on that hostname. The entrypoint pulls in the DO class, whose +// base class only exists in workerd. +vi.mock("cloudflare:workers", () => ({ + DurableObject: class {}, + WorkerEntrypoint: class {}, +})); + +const AUD = "a".repeat(64); + +describe("signInMode", () => { + it("is access once a real team domain and AUD are set", () => { + expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.cloudflareaccess.com", ACCESS_AUD: AUD })).toBe("access"); + expect(signInMode({ ACCESS_TEAM_DOMAIN: " Acme.CloudflareAccess.com ", ACCESS_AUD: ` ${AUD.toUpperCase()} ` })).toBe( + "access" + ); + }); + + it("is unconfigured for a fresh deploy's empty or placeholder values", () => { + expect(signInMode({ ACCESS_TEAM_DOMAIN: "", ACCESS_AUD: "" })).toBe("unconfigured"); + expect(signInMode({ ACCESS_TEAM_DOMAIN: "your-team.cloudflareaccess.com", ACCESS_AUD: AUD })).toBe("unconfigured"); + expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.cloudflareaccess.com", ACCESS_AUD: "" })).toBe("unconfigured"); + expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.example.com", ACCESS_AUD: AUD })).toBe("unconfigured"); + expect(signInMode({ ACCESS_TEAM_DOMAIN: "acme.cloudflareaccess.com", ACCESS_AUD: "not-hex" })).toBe("unconfigured"); + }); + + it("is dev when DEV_NO_AUTH is on, whatever Access says", () => { + expect(signInMode({ ACCESS_TEAM_DOMAIN: "", ACCESS_AUD: "", DEV_NO_AUTH: "true" })).toBe("dev"); + }); +}); + +describe("GET /api/setup", () => { + it("is public and answers with one word", async () => { + expect(isPublicPath("/api/setup")).toBe(true); + const { default: worker } = await import("./index.js"); + const env = { ACCESS_TEAM_DOMAIN: "", ACCESS_AUD: "", ASSETS: { fetch: vi.fn() } } as unknown as Env; + const res = await worker.fetch(new Request("https://flow.example.com/api/setup"), env, {} as ExecutionContext); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ signIn: "unconfigured" }); + }); +}); diff --git a/apps/web/index.html b/apps/web/index.html index d3ff130..abf0aa9 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -37,6 +37,10 @@ in src/lib/early-ws.ts). -->