Cloudsteading
Projektor issue backlog and status controls in the upstream repository

Projektor

Track team issues, boards and a project wiki on your Cloudflare account.

Projektor is a self-hosted Jira/Trello alternative built on Cloudflare (D1, Durable Objects, KV, R2, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: MIT

License TL;DR

You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.

Explain MIT in plain English →

Summary of the main license. Separate packages and assets can have different terms.

Inspect repository ↗Read this project’s actual license ↗

Repository owner

@TAJD

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The documented Projektor deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs.

Hosting requirements
  • Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits.
  • Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows.
  • Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes.
  • Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account.
  • Use the configured SQLite Durable Object classes within 100,000 requests/day, 13,000 GB-s duration/day, 5 million SQL rows read/day, 100,000 written/day and 5 GB storage; active sockets consume duration.
  • Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
  • Use an eligible Cloudflare Access Free proof-of-concept entitlement and confirm its current user limits; paid Access seats are separate if needed.
Check current pricing ↗
Sources checked 01/10/2026

Repository snapshot: 4329b3e. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • jira ↗

    > **AI-native project management, self-hosted on Cloudflare.**

  • trello ↗

    > **AI-native project management, self-hosted on Cloudflare.**

  • workers ↗

    e. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Documents. `global_fetch_strictly_public` stops the outbound CIMD fetch using # legacy same-zone origin routing (SSRF protection); `cache_option_enabled` allows

  • d1 ↗

    # Cron expressions are UTC; 03:00 UTC is off-peak for all deployed regions so far. [triggers] crons = ["0 3 * * *"] [[d1_databases]] binding = "DB" database_name = "projektor" database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID" migrations_dir = "../../packages/db/migrations" [[kv_namespaces]] binding = "KV" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [[kv_namespaces]] # PROJ-656/657: OAuth grants, authorization codes and access/refresh tokens. The # binding name is fixed by the librar

  • kv ↗

    dflare resource IDs for the deployed # instance live in the private projektor-workspace repo's wrangler.toml - never # commit real account/database/KV IDs here. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Doc

  • r2 ↗

    e of one cache never silently signs everyone out. binding = "OAUTH_KV" id = "REPLACE_WITH_YOUR_OAUTH_KV_NAMESPACE_ID" [[r2_buckets]] binding = "R2" bucket_name = "projektor-files" # [[durable_objects.bindings]] # name = "WORKSPACE_HUB" # class_name = "WorkspaceHub" # # Always keep this migration, even with the binding above commented out: the class # ships in every build, and wrangler only applies migrations after the last deployed # tag, so adding "v1" later (after "v2") would nev

  • durable-objects ↗

    r (a D1 write on every request) and logs a warning; # that fallback is removed in a later release (PROJ-924). [[durable_objects.bindings]] name = "RATE_LIMITER" class_name = "RateLimiter" [[migrations]] tag = "v2" new_sqlite_classes = ["RateLimiter"] [vars] ENVIRONMENT = "development" CF_ACCESS_TEAM_DOMAIN = "" CF_ACCESS_AUDIENCE = "" # Login provisioning (services/provisioning.ts). ADMIN_EMAILS should include your # DEV_USER_EMAIL so the first local login auto-creates the default workspa

  • free-tier-eligible ↗

    e. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Documents. `global_fetch_strictly_public` stops the outbound CIMD fetch using # legacy same-zone origin routing (SSRF protection); `cache_option_enabled` allows

  • free-tier-eligible ↗

    # Cron expressions are UTC; 03:00 UTC is off-peak for all deployed regions so far. [triggers] crons = ["0 3 * * *"] [[d1_databases]] binding = "DB" database_name = "projektor" database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID" migrations_dir = "../../packages/db/migrations" [[kv_namespaces]] binding = "KV" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [[kv_namespaces]] # PROJ-656/657: OAuth grants, authorization codes and access/refresh tokens. The # binding name is fixed by the librar

  • free-tier-eligible ↗

    dflare resource IDs for the deployed # instance live in the private projektor-workspace repo's wrangler.toml - never # commit real account/database/KV IDs here. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Doc

  • free-tier-eligible ↗

    up>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 seconds)<br> Max of 15 minutes of CPU time per [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) or [Queue Consumer](https://developers.cloudflare.co

  • free-tier-eligible ↗

    oudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/observability/metrics-analytics/#query-via-the-graphql-api), or the [Cloudflare dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/workers/d1/). Select your D1 database, then vie

  • free-tier-eligible ↗

    cing/). | | Free plan<sup>1</sup> | Paid plan | | --- | --- | --- | | Keys read | 100,000 / day | 10 million/month, + $0.50/million | | Keys written | 1,000 / day | 1 million/month, + $5.00/million | | Keys deleted | 1,000 / day | 1 million/month, + $5.00/million | | List requests | 1,000 / day | 1 million/month, + $5.00/million | | Stored data | 1 GB | 1 GB, + $0.50/ GB-month | <sup>1</sup> The Workers Free plan includes limited Workers KV usage. All limits reset daily at 00:00 UTC. If you exceed any one of these limits, further operations of that type will fail with an error. Note Workers KV pricing for read, write and delete operations is on a per-key basis. Bulk read operations are billed by the amount

  • free-tier-eligible ↗

    infrequent access storage) for 1.1 GB, you will be billed for 2 GB. ### Free tier You can use the following amount of storage and operations each month for free. | | Free | | --- | --- | | Storage | 10 GB-month / month | | Class A Operations | 1 million requests / month | | Class B Operations | 10 million requests / month | | Egress (data transfer to Internet) | Free <sup>[1](#user-content-fn-1)</sup> | Caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. ### Storage usage Storage is billed using gigabyte-month (GB-month) as the billing metric. A GB-month is calculated by averaging the *peak* storage per day over a billing period (30 days). For examp

  • free-tier-eligible ↗

    jects are available both on Workers Free and Workers Paid plans. - **Workers Free plan**: Only Durable Objects with [SQLite storage backend](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/#create-sqlite-backed-durable-object-class) are available. - **Workers Paid plan**: Durable Objects with the SQLite storage backend are available. The [key-value storage backend](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/#storage-backends) is only available to accounts that already have a key-value-backed namespace. If you wish to downgrade from a Workers Paid plan to a Workers Free plan, you must first ensure that you have deleted all Durable Object namespaces with the key-value storage backend. On Workers Free plan: - If you exceed any one of the free tier limits, further operations of that type will fail with an error. - Daily free limits reset at 00:00 UTC. ## Compute billing Durable Objects are billed for compute duration (wall-clock time) while the Durable Object is actively running or is idle in memory but unable to [hibernate](https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/). Durable Objects that are idle and eligible for hibernation are not billed for duration, even before the runtime has hibernated them. Requests to a D

  • free-tier-eligible ↗

    billed accordingly. | | Free plan | Paid plan | | --- | --- | --- | | Requests | 100,000 / day | 1 million / month, + $0.15/million<br> Includes HTTP requests, RPC sessions<sup>1</sup>, WebSocket messages<sup>2</sup>, and alarm invocations | | Duration<sup>3</sup> | 13,000 GB-s / day | 400,000 GB-s / month, + $12.50/million GB-s<sup>4,5</sup> | <details> <summary> Footnotes </summary> <sup>1</sup> Each <a href="https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/">RPC session</a> is billed as one request to your Durable Object. Every <a href="https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/">RPC method call</a> on a <a href="https://developers.cloudflare.com/durable-objects/">Durable Objects stub</a> is its own RPC session and therefore a single billed request. RPC method calls can return objects (stubs) extending <a href="https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/#lifetimes-memory-and-resource-management"><code>RpcTarget</code></a> and invo

  • free-tier-eligible ↗

    /). | | Workers Free plan | Workers Paid plan | | --- | --- | --- | | Rows reads <sup>1,2</sup> | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written <sup>1,2,3,4</sup> | 100,000 / day | First 50 million / month included + $1.00 / million rows | | SQL Stored data <sup>5</sup> | 5 GB (total) | 5 GB-month, + $0.20/ GB-month | <details> <summary> Footnotes </summary> <sup>1</sup> Rows read and rows written included limits and rates match <a href="https://developers.cloudflare.com/d1/platform/pricing/">D1 pricing</a>, Cloudflare's serverless SQL database. <sup>2</sup> Key-value methods like <code>get()</code>, <code>put()</code>, <code>delete()</code>, or <code>list(

  • free-tier-eligible ↗

    nt-blade-headline lh-1_1 headline-2 mb5 mb5-ns mb5-m mb0-l flex-1 f8">Start a proof of concept with our free plan today.</h2></div><div class="enablement-blade-actions flex flex-wrap ml0 ml0-ns ml0-m ml5-l" style="row-gap:20px;column-gap:20px"><a cla

  • MIT ↗

    MIT License Copyright (c) 2026 Thomas Dickson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONIN

  • architecture ↗

    e. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Documents. `global_fetch_strictly_public` stops the outbound CIMD fetch using # legacy same-zone origin routing (SSRF protection); `cache_option_enabled` allows

  • architecture ↗

    # Cron expressions are UTC; 03:00 UTC is off-peak for all deployed regions so far. [triggers] crons = ["0 3 * * *"] [[d1_databases]] binding = "DB" database_name = "projektor" database_id = "REPLACE_WITH_YOUR_D1_DATABASE_ID" migrations_dir = "../../packages/db/migrations" [[kv_namespaces]] binding = "KV" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [[kv_namespaces]] # PROJ-656/657: OAuth grants, authorization codes and access/refresh tokens. The # binding name is fixed by the librar

  • architecture ↗

    dflare resource IDs for the deployed # instance live in the private projektor-workspace repo's wrangler.toml - never # commit real account/database/KV IDs here. Local dev (`wrangler dev` + `--local` # migrations) uses Miniflare and does not need real IDs, so placeholders are fine. name = "projektor-api" main = "src/index.ts" compatibility_date = "2024-09-23" compatibility_flags = [ "nodejs_compat", # PROJ-656: required by @cloudflare/workers-oauth-provider for Client ID Metadata # Doc

  • architecture ↗

    e of one cache never silently signs everyone out. binding = "OAUTH_KV" id = "REPLACE_WITH_YOUR_OAUTH_KV_NAMESPACE_ID" [[r2_buckets]] binding = "R2" bucket_name = "projektor-files" # [[durable_objects.bindings]] # name = "WORKSPACE_HUB" # class_name = "WorkspaceHub" # # Always keep this migration, even with the binding above commented out: the class # ships in every build, and wrangler only applies migrations after the last deployed # tag, so adding "v1" later (after "v2") would nev

  • architecture ↗

    r (a D1 write on every request) and logs a warning; # that fallback is removed in a later release (PROJ-924). [[durable_objects.bindings]] name = "RATE_LIMITER" class_name = "RateLimiter" [[migrations]] tag = "v2" new_sqlite_classes = ["RateLimiter"] [vars] ENVIRONMENT = "development" CF_ACCESS_TEAM_DOMAIN = "" CF_ACCESS_AUDIENCE = "" # Login provisioning (services/provisioning.ts). ADMIN_EMAILS should include your # DEV_USER_EMAIL so the first local login auto-creates the default workspa

Upstream screenshot · TAJD/projektor repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

Jira logoJira ↗

Team issues, boards, sprints and project wiki content; complete commercial administration and marketplace integrations are excluded.

See supporting source ↗
external SaaS target
varies
external SaaS target
varies

How it works

The shape of Projektor on Cloudflare, and how it stacks up against the rented tools it replaces.

Architecture

Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.

View upstream source ↗
Public interface
Configured entry points2
projektor-api
apps/api/wrangler.toml
projektor-api-test
apps/api/wrangler.test.toml
↓
App
projektor-api
entry
Cloudflare Workers
Entrypoint: src/index.tsConfigured cron (UTC): 0 3 * * *
projektor-api-test
entry
Cloudflare Workers
Entrypoint: src/index.ts
↓

Configuration and workflow sources

Reviewed commit 4329b3e7febc. Files were read as data; upstream applications and CI jobs were not executed.

Partial source coverage: 62 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.

Deployment configuration · 3 files
apps/api/wrangler.toml ↗

Cloudflare Workers · compatibility 2024-09-23

projektor-api · default

Entrypoint: src/index.ts

Cron triggers (UTC): 0 3 * * *

  • DB → D1
  • KV → KV
  • OAUTH_KV → KV
  • R2 → R2
  • RATE_LIMITER → Durable Objects · class RateLimiter
apps/api/wrangler.test.toml ↗

Cloudflare Workers · compatibility 2024-09-23

projektor-api-test · default

Entrypoint: src/index.ts

  • DB → D1
  • KV → KV
  • OAUTH_KV → KV
  • R2 → R2
  • RATE_LIMITER → Durable Objects · class RateLimiter
scripts/release-assets/wrangler.example.toml ↗

Cloudflare Workers · example/template, excluded from overview · compatibility __COMPAT_DATE__

projektor · default

Entrypoint: ./vendor/worker.js

Static assets: ./vendor/web · Worker first: ["/api/*","/mcp/*","/wiki","/.well-known/*"]

Cron triggers (UTC): 0 3 * * *

  • DB → D1
  • KV → KV
  • OAUTH_KV → KV
  • R2 → R2
  • RATE_LIMITER → Durable Objects · class RateLimiter
  • ASSETS → Static assets

Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.

Runtime source · handlers, binding usage and workflow steps

Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.

apps/api/src/index.ts ↗
  • L857 · fetch handler exported · calls isOAuthProviderPath, tokenEndpointRateLimited, oauthProvider.fetch, isOAuthAccessToken, app.fetch
  • L869 · scheduled handler exported · calls ctx.waitUntil, purgeAllWorkspacesExpiredWikiPages, purgeExpiredOAuthData, purgeExpiredRetentionData, runFtsDedupeOnce
  • L88 · app.route("/api/feedback")
  • L90 · app.use("*")
  • L96 · app.use("*")
  • L110 · app.get("/health")
  • L113 · app.get("/api/health")
  • L114 · app.route("/api/config")
  • L119 · app.use("/api/*")
  • L120 · app.use("/mcp/*")
  • L124 · app.use("/.well-known/*")
  • L125 · app.route("/.well-known")
  • L130 · app.use("/oauth/*")
  • L131 · app.use("/oauth/*")
  • L132 · app.route("/oauth")
  • L136 · app.get("/bootstrap")
  • L224 · app.route("/api/share")
  • L227 · app.route("/auth")
  • L230 · app.get("/api/workspaces")
  • L235 · app.post("/api/workspaces")
  • L248 · app.use("/api/workspaces/:slug/*")
  • L250 · app.get("/api/projects")
  • L260 · app.use("/api/projects/*")
  • L261 · app.use("/api/issues/*")
  • L262 · app.use("/api/issue-links/*")
  • L263 · app.use("/api/wiki/*")
  • L264 · app.use("/mcp/*")
  • L274 · app.use("/api/files/*")
  • L275 · app.use("/api/files/*")
  • L276 · app.use("/api/task-types/*")
  • L277 · app.use("/api/task-statuses/*")
  • L278 · app.use("/api/custom-fields/*")
  • L279 · app.use("/api/sprints/*")
  • L280 · app.use("/api/agents/*")
  • L281 · app.use("/api/file-claims/*")
  • L282 · app.use("/api/issue-leases/*")
  • L283 · app.use("/api/agent-messages/*")
  • L284 · app.use("/api/feedback-sources/*")
  • L286 · app.use("/api/workflow/*")
  • L287 · app.use("/api/playbooks/*")

Environment references: c.env.ENVIRONMENT · c.env.BOOTSTRAP_SECRET · c.env.DEV_USER_EMAIL · c.env.DB · c.env.ASSETS · env.KV · env.DB · env.WIKI_NOTIFICATION_RETENTION_DAYS · env.AGENT_SESSION_RETENTION_DAYS · env.ACTIVITY_RETENTION_DAYS · env.R2

apps/api/src/http/body.ts ↗
  • L14 · jsonBody calls (conditional paths may differ): c.req.json
apps/api/src/http/error-adapter.ts ↗
  • L12 · serviceErrToResponse calls (conditional paths may differ): c.json
apps/api/src/lib/urls.ts ↗
  • L1 · slugifyForUrl calls (conditional paths may differ): replace, text.toLowerCase
  • L13 · issuePath calls (conditional paths may differ): slugifyForUrl
  • L21 · wikiPagePath calls (conditional paths may differ): encodeURIComponent
  • L29 · safeDecodeURIComponent calls (conditional paths may differ): decodeURIComponent
apps/api/src/lib/wiki-ssr.ts ↗
  • L18 · resolveWikiWorkspaceContext calls (conditional paths may differ): c.req.header, subdomainRoutingEnabled, c.get, first, bind, c.env.DB.prepare
  • L54 · resolveWikiPageForSsr calls (conditional paths may differ): authMiddleware, resolveWikiWorkspaceContext, c.set, getWikiPage, ctxFromHono
  • L79 · plainTextExcerpt calls (conditional paths may differ): trim, replace, markdown.replace, plain.slice
  • L111 · injectWikiMetadata calls (conditional paths may differ): plainTextExcerpt, transform, on

Environment references: c.env.WORKSPACE_SUBDOMAIN_ROUTING · c.env.DEFAULT_WORKSPACE_SLUG · c.env.DB

apps/api/src/middleware/auth.ts ↗
  • L35 · requireInteractiveHuman calls (conditional paths may differ): c.get, isPublicViewer, c.json
  • L44 · isTruthy calls (conditional paths may differ): includes
  • L53 · tooManyAuthFailures calls (conditional paths may differ): c.req.header, parseInt, bumpRateCounter, console.error, String
  • L101 · oauthGrantProps calls (conditional paths may differ): Array.isArray
  • L109 · tryOAuthGrantAuth calls (conditional paths may differ): oauthGrantProps, filter, props.scopes.map, checkTokenScope, c.set, oauthGrantIdFromRequest
  • L146 · oauthGrantIdFromRequest calls (conditional paths may differ): c.req.header, header.startsWith, split, header.slice
  • L153 · tryCfAccessAuth calls (conditional paths may differ): c.req.header, parseCookie, validateCfAccessJwt, c.json, ensureUserProvisioned, c.set
  • L178 · tooManyAuthFailuresResponse calls (conditional paths may differ): tooManyAuthFailures, c.json
  • L191 · checkTokenScope calls (conditional paths may differ): c.req.path.startsWith, capabilityForMethod, tokenAllows, c.json
  • L209 · authenticateApiToken calls (conditional paths may differ): hashToken, first, bind, c.env.DB.prepare, tooManyAuthFailuresResponse, Date.now, parseScopes, checkTokenScope, c.set, token.startsWith, Math.floor, c.executionCtx.waitUntil, run
  • L265 · tryBearerTokenAuth calls (conditional paths may differ): c.req.header, authHeader.slice, authenticateApiToken
  • L274 · tryDevBypassAuth calls (conditional paths may differ): mcpWorkspaceIdFromPath, upsertUserByEmail, ensureUserProvisioned, c.set
  • L296 · tryPublicViewerAuth calls (conditional paths may differ): isTruthy, upsertUserByEmail, provisionPublicViewer, c.set
  • L316 · withMcpAuthChallenge calls (conditional paths may differ): mcpWorkspaceIdFromPath, response.headers.set, unauthorizedChallenge
  • L324 · authMiddleware calls (conditional paths may differ): attempt, withMcpAuthChallenge, next, c.json
  • L366 · decodeJwtFields calls (conditional paths may differ): JwtHeaderSchema.safeParse, JSON.parse, base64urlDecode, JwtPayloadSchema.safeParse
  • L380 · jwtClaimsValid calls (conditional paths may differ): Math.floor, Date.now, Array.isArray, aud.includes
  • L398 · verifySignatureAgainstKeys calls (conditional paths may differ): encode, base64urlToUint8Array, crypto.subtle.importKey, crypto.subtle.verify
  • L421 · verifyJwtPayload calls (conditional paths may differ): jwt.split, decodeJwtFields, jwtClaimsValid, verifySignatureAgainstKeys
  • L442 · preScreenCfAccessJwt calls (conditional paths may differ): decodeJwtFields, jwtClaimsValid
  • L453 · validateCfAccessJwt calls (conditional paths may differ): jwt.split, preScreenCfAccessJwt, getCfAccessKeysOrUnavailable, verifyJwtPayload, upsertUserByEmail
  • L499 · fetchAndCacheCfAccessKeys calls (conditional paths may differ): fetch, res.json, env.KV.put, JSON.stringify, console.error, Date.now
  • L515 · getCfAccessKeysOrUnavailable calls (conditional paths may differ): getCfAccessKeys
  • L526 · getCfAccessKeys calls (conditional paths may differ): Date.now, fetchAndCacheCfAccessKeys, env.KV.get, console.error
  • L565 · resetAuthCachesForTests calls (conditional paths may differ): inMemoryUserCache.clear
  • L570 · upsertUserByEmail calls (conditional paths may differ): inMemoryUserCache.get, Date.now, kv.get, console.error, inMemoryUserCache.set, crypto.randomUUID, email.split, Math.floor, run, bind, db.prepare, first, kv.put, JSON.stringify
  • L627 · parseCookie calls (conditional paths may differ): cookieHeader.split, split, part.trim, k.trim, trim, rest.join
  • L635 · base64urlDecode calls (conditional paths may differ): replace, s.replace, slice, atob
  • L640 · base64urlToUint8Array calls (conditional paths may differ): base64urlDecode, Uint8Array.from, c.charCodeAt
  • L645 · hashToken calls (conditional paths may differ): crypto.subtle.digest, encode, join, map, Array.from, padStart, b.toString

Environment references: c.env.RATE_LIMIT_WINDOW_SECS · c.env.RATE_LIMIT_AUTH_FAIL_MAX · c.env.DB · c.env.ENVIRONMENT · c.env.DEV_USER_EMAIL · c.env.PUBLIC_READ_ONLY · c.env.KV · env.CF_ACCESS_AUDIENCE · env.CF_ACCESS_TEAM_DOMAIN · env.DB · env.KV

apps/api/src/middleware/etag.ts ↗
  • L22 · etagMiddleware calls (conditional paths may differ): next, arrayBuffer, c.res.clone, sha256Prefix, ifNoneMatchMatches, c.req.header, c.res.headers.set
  • L45 · ifNoneMatchMatches calls (conditional paths may differ): header.trim, replace, t.trim, some, header.split, stripWeak
  • L52 · sha256Prefix calls (conditional paths may differ): crypto.subtle.digest, join, map, Array.from, slice, padStart, b.toString
apps/api/src/middleware/rate-limit.ts ↗
  • L21 · rateLimitMiddleware calls (conditional paths may differ): parseInt, Number, Number.isFinite, Date.now, Math.floor, c.req.header, authHeader.slice, oauthGrantKey, sha256Prefix, incrementCounter, console.error, String, next, c.header, c.json
  • L78 · oauthGrantKey calls (conditional paths may differ): token.split
  • L89 · bumpRateCounter calls (conditional paths may differ): incrementCounter, Math.floor, Date.now
  • L94 · sha256Prefix calls (conditional paths may differ): crypto.subtle.digest, encode, join, map, Array.from, slice, padStart, b.toString
  • L109 · pruneStaleRateLimitRows calls (conditional paths may differ): Math.floor, Date.now, bind, db.prepare
  • L129 · incrementCounter calls (conditional paths may differ): ns.get, ns.idFromName, rateLimiterObjectName, setTimeout, reject, Promise.race, stub.increment, clearTimeout, console.warn, Math.floor, incrementD1Counter
  • L159 · incrementD1Counter calls (conditional paths may differ): bind, db.prepare, Math.random, statements.push, pruneStaleRateLimitRows, db.batch

Environment references: c.env.RATE_LIMIT_WINDOW_SECS · c.env.ENVIRONMENT · c.env.RATE_LIMIT_TEST_NOW_MS · c.env.RATE_LIMIT_API_MAX · c.env.RATE_LIMIT_AUTH_MAX · env.ENVIRONMENT · env.RATE_LIMIT_TEST_EPOCH · env.RATE_LIMITER · env.DB

apps/api/src/middleware/workspace.ts ↗
  • L6 · subdomainRoutingEnabled calls (conditional paths may differ): includes
  • L13 · subdomainCandidate calls (conditional paths may differ): host.split, test, hostname.split
  • L25 · warnIfIgnoredSubdomain calls (conditional paths may differ): subdomainCandidate, c.req.header, first, bind, c.env.DB.prepare, console.warn
  • L50 · mcpWorkspaceIdFromPath calls (conditional paths may differ): exec
  • L66 · missingWorkspaceResponse calls (conditional paths may differ): warnIfIgnoredSubdomain, c.json
  • L96 · resolveWorkspaceTarget calls (conditional paths may differ): c.get, c.req.query, c.req.header, mcpWorkspaceIdFromPath
  • L112 · workspaceMiddleware calls (conditional paths may differ): c.req.header, subdomainRoutingEnabled, resolveWorkspaceTarget, missingWorkspaceResponse, c.get, first, bind, c.env.DB.prepare, c.json, tokenConfinedToOtherWorkspace, c.set, next

Environment references: c.env.DB · c.env.WORKSPACE_SUBDOMAIN_ROUTING

apps/api/src/oauth/provider.ts ↗
  • L30 · isOAuthAccessToken calls (conditional paths may differ): request.headers.get, split, header.slice
  • L69 · createOAuthProvider calls (conditional paths may differ): providerOptions
  • L85 · oauthApi calls (conditional paths may differ): getOAuthApi, providerOptions
  • L100 · tokenEndpointRateLimited calls (conditional paths may differ): request.headers.get, parseInt, bumpRateCounter, console.error, String, Response.json

Environment references: env.RATE_LIMIT_WINDOW_SECS · env.RATE_LIMIT_AUTH_MAX

apps/api/src/routes/agent-messages.ts ↗
  • L10 · router.get("/")
  • L20 · router.post("/")
apps/api/src/routes/agents.ts ↗
  • L18 · router.post("/start-work")
  • L27 · router.post("/finish-work")
  • L36 · router.get("/")
  • L46 · router.post("/")
  • L55 · router.post("/:id/heartbeat")
  • L64 · router.post("/:id/end")
apps/api/src/routes/auth.ts ↗
  • L14 · router.get("/login")
  • L20 · router.get("/me")
  • L37 · router.post("/tokens")
  • L49 · router.delete("/tokens/:id")
  • L10 · isSafeRedirectPath calls (conditional paths may differ): url.startsWith

Environment references: c.env.DB

apps/api/src/routes/code-heatmap.ts ↗
  • L9 · router.get("/:id/code-heatmap")
apps/api/src/routes/comments.ts ↗
  • L14 · router.get("/:issueId/comments")
  • L24 · router.post("/:issueId/comments")
  • L35 · router.patch("/:issueId/comments/:id")
  • L47 · router.delete("/:issueId/comments/:id")
apps/api/src/routes/config.ts ↗
  • L15 · router.get("/brand")
  • L10 · deriveBrandMark calls (conditional paths may differ): name.trim, toUpperCase, firstChar

Environment references: c.env.BRAND_NAME · c.env.BRAND_MARK · c.env.BRAND_ACCENT · c.env.BRAND_ON_ACCENT · c.env.BRAND_LOGO_URL

apps/api/src/routes/custom-fields.ts ↗
  • L15 · router.get("/")
  • L25 · router.post("/")
  • L34 · router.patch("/:id")
  • L43 · router.delete("/:id")
apps/api/src/routes/feedback.ts ↗
  • L26 · publicRouter.use("*")
  • L91 · publicRouter.post("/submit")
  • L115 · authedRouter.get("/:id/feedback")
  • L127 · authedRouter.get("/:id/feedback/summary")
  • L137 · authedRouter.patch("/:id/feedback/:feedbackId")
  • L149 · authedRouter.post("/:id/feedback/bulk-mark-reviewed")
  • L160 · authedRouter.post("/:id/feedback/bulk-convert-to-issue")
  • L171 · authedRouter.post("/:id/feedback/:feedbackId/convert-to-issue")
  • L42 · checkFeedbackRateLimit calls (conditional paths may differ): parseInt, c.req.header, hashFeedbackToken, Promise.all, bumpRateCounter, console.error, String, c.json
  • L73 · parseFeedbackBody calls (conditional paths may differ): jsonBody
  • L81 · submitFeedbackErrorResponse calls (conditional paths may differ): c.json

Environment references: c.env.RATE_LIMIT_WINDOW_SECS · c.env.RATE_LIMIT_FEEDBACK_MAX · c.env.RATE_LIMIT_FEEDBACK_IP_MAX · c.env.DB

apps/api/src/routes/feedback-sources.ts ↗
  • L17 · router.post("/:id/feedback-sources")
  • L28 · router.get("/:id/feedback-sources")
  • L38 · router.patch("/:id/feedback-sources/:sourceId")
  • L50 · router.post("/:id/feedback-sources/:sourceId/rotate")
  • L61 · router.delete("/:id/feedback-sources/:sourceId")
  • L74 · lookupRouter.get("/:sourceId")
apps/api/src/routes/file-claims.ts ↗
  • L10 · router.get("/")
  • L20 · router.post("/")
  • L29 · router.post("/release")
apps/api/src/routes/files.ts ↗
  • L14 · router.get("/")
  • L27 · router.post("/links")
  • L62 · router.post("/")
  • L110 · router.get("/:id/metadata")
  • L119 · router.get("/:id")
  • L155 · router.delete("/:id")
  • L38 · parseUploadFormData calls (conditional paths may differ): c.req.formData, c.json
  • L46 · extractUploadInput calls (conditional paths may differ): formData.get, c.json, EntityTypeEnum.safeParse

Environment references: c.env.R2

apps/api/src/routes/flow-metrics.ts ↗
  • L9 · router.get("/:id/flow-metrics")
apps/api/src/routes/groups.ts ↗
  • L21 · router.get("/:slug/groups")
  • L30 · router.post("/:slug/groups")
  • L41 · router.get("/:slug/member-groups")
  • L50 · router.get("/:slug/groups/:groupId")
  • L59 · router.patch("/:slug/groups/:groupId")
  • L68 · router.delete("/:slug/groups/:groupId")
  • L77 · router.post("/:slug/groups/:groupId/members")
  • L86 · router.delete("/:slug/groups/:groupId/members/:userId")
  • L95 · router.put("/:slug/groups/:groupId/grants")
  • L104 · router.delete("/:slug/groups/:groupId/grants/:projectId")
Build and deployment pipeline · 5 GitHub Actions workflows

Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.

CI · .github/workflows/ci.yml ↗

Triggers: pull_request, workflow_call

Test & type-check · no job dependencies declared

  1. actions/checkout@v4actions/checkout@v4
  2. pnpm/action-setup@v4pnpm/action-setup@v4
  3. actions/setup-node@v4actions/setup-node@v4
  4. Shell commandpnpm install --frozen-lockfile
  5. Generated docs are freshpnpm gen:docs git diff --exit-code -- apps/docs/src/content/docs/agents/tool-catalog.md \ apps/docs/src/content/docs/agents/workflow-spec.md \ apps/docs/src/generated/mcp-stats.json README.md \ apps/docs/src/content/docs/contributing/conventions.md \ apps/docs/src/content/docs/guides/feedback-widget-integration.md \ || { echo "::error::Generated docs are stale. Run 'pnpm gen:docs' and commit the result."; exit 1; } # A directory, not a fixed file list (PLAYBOOKS can gain/lose entries), so # `gi…
  6. Shell commandpnpm lint
  7. Shell commandpnpm turbo type-check
  8. Shell commandpnpm --filter @projektor/db test
  9. Shell commandpnpm --filter @projektor/api test:coverage
  10. Shell commandpnpm --filter @projektor/web test:coverage
  11. Shell commandpnpm --filter @projektor/web build
  12. Bundle size budget (PROJ-841)pnpm bundle-budget
  13. Shell commandpnpm --filter @projektor/docs build
Docs · .github/workflows/docs.yml ↗

Triggers: push, workflow_dispatch

Build site · no job dependencies declared

  1. actions/checkout@v4actions/checkout@v4
  2. pnpm/action-setup@v4pnpm/action-setup@v4
  3. actions/setup-node@v4actions/setup-node@v4
  4. Shell commandpnpm install --frozen-lockfile
  5. Shell commandpnpm gen:docs
  6. Shell commandpnpm --filter @projektor/docs build
  7. Shell commandnode scripts/submit-indexnow.mjs
  8. actions/upload-pages-artifact@v3actions/upload-pages-artifact@v3

Deploy to Pages · after build

  1. actions/deploy-pages@v4actions/deploy-pages@v4
Prepare release · .github/workflows/release-prepare.yml ↗

Triggers: workflow_dispatch

Bump version and open PR · no job dependencies declared

  1. actions/checkout@v4actions/checkout@v4
  2. Bump versionNEW_VERSION=$(npm version "${{ inputs.bump }}" --no-git-tag-version | sed 's/^v//') echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
  3. Open release PRVERSION="${{ steps.bump.outputs.version }}" BRANCH="chore/release-v$VERSION" git checkout -b "$BRANCH" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add apps/web/package.json git commit -m "chore(web): bump version to $VERSION for release" git push -u origin "$BRANCH" gh pr create \ --title "chore(web): bump version to $VERSION for release" \ --body "Automated version bump ahead of cutting v$VERSION. Merging this will trigger…
Tag release · .github/workflows/release-tag.yml ↗

Triggers: pull_request

Create and push release tag · no job dependencies declared

Condition: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')

  1. actions/checkout@v4actions/checkout@v4
  2. Determine versionVERSION=$(node -p "require('./apps/web/package.json').version") echo "version=$VERSION" >> "$GITHUB_OUTPUT"
  3. Tag and pushTAG="v${{ steps.version.outputs.version }}" if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then echo "::error::Tag $TAG already exists — skipping to avoid re-triggering release.yml." exit 1 fi git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "$TAG" git push origin "$TAG"
Release · .github/workflows/release.yml ↗

Triggers: push, workflow_dispatch

CI checks · no job dependencies declared

Uses: ./.github/workflows/ci.yml

    Build & publish release artifact · after ci

    1. actions/checkout@v4actions/checkout@v4
    2. pnpm/action-setup@v4pnpm/action-setup@v4
    3. actions/setup-node@v4actions/setup-node@v4
    4. Shell commandpnpm install --frozen-lockfile
    5. Build release artifactbash scripts/build-release.sh "$VERSION"
    6. Create GitHub Release# A hyphen in the version means a pre-release (v1.2.0-rc.1). Marking it # as such keeps it out of "latest", so a deploy repo pinned to latest # never picks up a release candidate by accident. PRERELEASE="" case "$VERSION" in *-*) PRERELEASE="--prerelease" ;; esac gh release create "$VERSION" "dist/projektor-$VERSION.tar.gz" \ --title "$VERSION" \ --generate-notes $PRERELEASE
    7. Notify deploy repogh api "repos/${{ vars.DEPLOY_DISPATCH_REPO }}/dispatches" \ -f event_type=projektor-release \ -F "client_payload[version]=$VERSION"Condition: vars.DEPLOY_DISPATCH_REPO != ''
    package.json ↗
    • build: turbo build
    • deploy: turbo deploy
    apps/api/package.json ↗
    • build: wrangler deploy --dry-run --outdir dist
    • deploy: wrangler deploy
    apps/docs/package.json ↗
    • build: astro build && node scripts/gen-llms-txt.mjs
    apps/web/package.json ↗
    • build: astro build && node scripts/assert-sw.mjs && node scripts/assert-eager-chunks.mjs && node scripts/gen-csp-headers.mjs

    Full upstream document by @TAJD · README.md · snapshot 4329b3e

    projektor

    CI Latest release License: MIT

    AI-native project management, self-hosted on Cloudflare.

    Docs · Live demo · Deploy your own

    Projektor issue backlog - list view with projects sidebar, issue refs, status, priority, and assignees

    What it is

    Projektor is an issue tracker and wiki that an AI coding agent runs as well as you do. It holds issues, boards, sprints and a wiki, and it exposes

    122 tools across 22 domains over MCP,

    so the agent files the ticket, moves it and writes the page instead of asking you to. The whole thing is one Cloudflare Worker in your own account.

    Connect it to Claude

    Add your instance as a connector in Claude — web, desktop, mobile, or Claude Code — and sign in with the account you already use for the web UI. There is no token to copy into a config file.

    Projektor is an OAuth 2.1 authorization server: discovery over RFC 9728 / RFC 8414, client identity via CIMD rather than dynamic registration, PKCE throughout, and an RFC 8707 resource parameter that binds every grant to exactly one workspace — so a token minted for one workspace is not a credential for another. Each authorization is personal, expires on its own, and appears in your settings with a Disconnect beside it.

    A grant never exceeds what you can already do. Your live workspace role is checked on every call, so the connector inherits your ceiling rather than the scopes it asked for. See connecting an agent for the flow and deploying for the Cloudflare Access carve-outs it needs.

    One work graph, not a tracker plus a sidecar

    Running a fleet of agents normally means assembling three things: a tracker for the work, a coordination layer so two agents do not edit the same file, and worktree tooling to keep their checkouts apart. That leaves two or three sources of truth about what is being worked on, and nothing that can answer a question spanning them.

    In Projektor the coordination state is the work graph. The lease is on the issue and the claim is on the file, in one schema behind one auth boundary:

    • Issue leases — an agent takes a work-item lease before starting, and a per-project cap (agent_wip_limit, default 3) bounds how many issues the fleet can hold at once. That is admission control on the backlog, not a rate limit: it decides how much work is allowed to be in flight.
    • File claims — path-level claims stop two agents editing the same code. A refused claim is rejected whole and names the issue and agent already holding the path, so the blocked agent knows who to talk to, and every contended path is recorded.
    • Liveness — both tiers derive from the holder's heartbeat, so a lease or claim whose agent stopped reporting is reclaimed by the next claim in the same call. An agent that crashes mid-ticket does not deadlock the backlog, or the files, behind it. Reclaiming a dead holder is not logged as contention — the coordination model covers why, and the one case that still needs a manual force.

    And because it is deployed rather than local, the fleet is not limited to one machine. A coordination store on somebody's laptop can only be consulted by processes on that laptop: a CI runner cannot take a lease, an agent on a second machine cannot see the first one's claims, and a hosted agent cannot participate. Those participants are excluded by construction, not by throughput — and exposing a localhost server to fix it means taking on TLS, auth and uptime, which is the deployment problem you were avoiding. Projektor's ceiling is a property of the deployment — one that can be raised without changing how anything works — rather than a property of a laptop that also happens to be running the agents.

    Because it is one graph, "which issue is this claim for" is a join, not an integration. The coordination model documents the design. How Projektor differs compares it against beads, MCP Agent Mail, Linear and the worktree tools, and is honest about what each of those does better.

    Contention is data

    When a claim collides, most systems return an error and forget it. Projektor writes the contended path to an event log — whether the claim was refused or forced through — and ranks it, so the code heatmap has two modes: where the fleet is working, and where the fleet is colliding.

    That closes a loop back into the backlog. A directory that shows up hot in contention week after week is telling you something about how the work is sliced — two tickets that keep fighting over the same module probably wanted to be one ticket, or the module wanted splitting. Refused claims are evidence about your plan, not just failures.

    Both surfaces, checked mechanically

    REST and MCP are two doors into one service layer:

    REST  /api/*         ─┐
                          ├─►  services/<domain>.ts  ─►  D1 (SQLite)
    MCP   /mcp/:wsId     ─┘
    

    Routes and MCP tools are thin wrappers; the business logic and the SQL live in services/. An agent is not driving a reduced API built for robots, it is calling the code the browser calls.

    That used to be a convention held up by review. It is now a test: mcp-parity.node.test.ts cross-references every exported service function against the REST routes, the runtime MCP registry and the documented catalog, and fails the build on drift — including a catalog reordered without the dispatch table. Operations deliberately on one surface only are enumerated with a reason each, and the test fails if that list goes stale, so the exceptions stay few and visible rather than accumulating quietly.

    Every surface described here was used to build Projektor: the epics, the tickets, the coordination primitives and the wiki pages all carried their own development. The tool catalog is a discovered surface, not a designed one.

    You still run the project

    Agents do the filing. You do the steering, and you do it in a normal web app: backlog and kanban board, epics, sprint planning, a nested wiki with full-text search and revision history, flow metrics, and a feedback widget that turns user reports into issues. Nothing an agent does is buried in a log - it lands on the board, where you can read it, argue with it and move it.

    What is in it

    • Issues - status, priority, assignee, labels, parent/child hierarchy and cross-issue links. Referenced as PROJ-42.
    • Boards and sprints - kanban board, list view, sprint planning, flow metrics.
    • Wiki - nested markdown pages, full-text search, revision history.
    • MCP server - the primary surface, not an add-on. Any MCP agent connects; Claude Code does it with claude mcp add.
    • Fleet coordination - agent registry, issue leases with a per-project WIP cap, file claims, agent messages, and a contention heatmap over every contended path.
    • Ops - file attachments, workspaces, projects and members with roles, scoped API tokens, share links, installable PWA.

    Serverless on your own Cloudflare account: Hono on Workers, D1 for data, KV for cache, R2 for attachments. No servers, no containers.

    Deploy it

    Projektor installs into your Cloudflare account from a small config-only repo that downloads a pre-built release. There is no source checkout and no build step. The one-click Deploy to Cloudflare button in projektor-deploy-example provisions D1, KV and R2 for you and deploys. A script and a CI flow do the same job; see the deploy guide.

    Put Cloudflare Access in front of the Worker before anyone logs in, then mint a token for your agents. CONFIGURE.md covers both.

    Connect an agent

    Projektor serves a JSON-RPC 2.0 MCP endpoint at POST /mcp/<workspaceId>. Open Settings → Tokens, create a token, and copy the claude mcp add command shown beside it with the workspace and token filled in. The agent connection guide has the protocol reference and the full tool catalog.

    Where to go next

    Getting started first workspace, projects, issues
    Self-hosting and deploying Cloudflare setup, API token scopes, updates
    MCP connection and tool catalog wiring an agent up, every tool it gets
    Agentic workflows how agents are meant to use the tracker
    Coordination model leases, claims, liveness and contention as a designed system
    How Projektor differs compared against beads, MCP Agent Mail, Hiveship, Linear
    System design the two surfaces and the service layer beneath them
    AGENTS.md contributor guide: conventions, file layout, the service-layer contract

    Development

    pnpm install
    
    cp apps/api/.dev.vars.example apps/api/.dev.vars   # set DEV_USER_EMAIL + BOOTSTRAP_SECRET
    cp apps/web/.env.example      apps/web/.env        # set PUBLIC_WORKSPACE_SLUG=projektor
    
    pnpm dev   # API on :8787, web on :4321
    

    pnpm dev applies D1 migrations to the local Miniflare database first, so a fresh checkout will not 500 with "no such table". Seed a workspace with curl -H "X-Bootstrap-Secret: localdev" http://127.0.0.1:8787/bootstrap, then open http://localhost:4321. With DEV_USER_EMAIL set, the dev-auth bypass logs you in.

    pnpm --filter @projektor/api test   # vitest against an in-process Worker + Miniflare D1
    pnpm turbo type-check               # tsc --noEmit across the monorepo
    

    Both must be green before you open a PR. pnpm install also wires the lefthook pre-commit and pre-push hooks. CI runs more; AGENTS.md lists it, along with the engineering conventions.

    Contributing

    Projektor is built with itself - the live dogfood instance tracks its own bugs and feature requests. CONTRIBUTING.md explains how issues and PRs are handled, SECURITY.md how to report a vulnerability, and AGENTS.md what to read before opening a PR. Licensed under MIT.

    Frequently asked about Projektor

    What is Projektor?+

    Projektor is a self-hosted Jira/Trello alternative built on the Cloudflare developer platform. Track team issues, boards and a project wiki on your Cloudflare account.

    What does Projektor replace?+

    Projektor is listed as an alternative to Jira, Trello. Compare the features and tradeoffs before migrating.

    What Cloudflare primitives does Projektor use?+

    Projektor is built on D1, Durable Objects, KV, R2, Workers.

    How much does Projektor cost to run?+

    The documented Projektor deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs. Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits. Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows. Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes. Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account. Use the configured SQLite Durable Object classes within 100,000 requests/day, 13,000 GB-s duration/day, 5 million SQL rows read/day, 100,000 written/day and 5 GB storage; active sockets consume duration. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Use an eligible Cloudflare Access Free proof-of-concept entitlement and confirm its current user limits; paid Access seats are separate if needed. Check current Cloudflare pricing before deploying.

    Is Projektor open source?+

    The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/TAJD/projektor/4329b3e7febc0a9a500d0a1bd2a820a4d6852218/LICENSE. Source code and contributor credit are available at https://github.com/TAJD/projektor.

    Discussion · 0

    sign in to comment →
    No comments yet — be the first.