This file describes how TaskCrew protects data that needs more than the normal access rules.
Vault#
A vault holds shared secrets of seven types: logins (website, username, password, 2FA setup key), 2FA codes, SSH keys, API keys, secure notes, files (certificates, .env files…) and database connections. Every entry can have notes with instructions, a link to a wiki page and extra fields. We decided to ship vaults because the risks below can be handled honestly. Where a risk remains, it is listed under "Limits" instead of being hidden.
What is encrypted, and how#
- The password, notes, 2FA key, extra fields, SSH private key or API key, and files of an entry are stored as one encrypted blob (
vault_entries.secretEnc). It uses AES-256-GCM with the server key (ENCRYPTION_KEY) through the sameSecretBoxhelper that protects connector tokens (packages/db/src/crypto.ts). Every write gets a new random 96-bit IV. The blob also contains the vault's id and is refused when it is found in another vault's row, so a ciphertext copied between rows in the database cannot be revealed elsewhere. - Stored in clear (needed for the list, search and sorting): title, type, username, website, your tags, timestamps, the facts in
meta(server, port and database name, an SSH key's public half and fingerprint, an expiry date, a linked wiki page, the names and sizes of files), and a few facts about the secret kept as system tags: a strength score from 0 to 4 (logins and databases), and whether a password, 2FA key, notes, extra fields or a key exist. None of these lets anyone rebuild a secret. - Plaintext secrets are never stored, logged, cached or sent anywhere except in the response to an explicit reveal. All decryption happens in one class (
apps/api/src/modules/vault/vault-secrets.ts), called only by "reveal" and by "update" (to merge a change). A guard test (vault.guard.spec.ts) fails when other code selects the encrypted column, decrypts it elsewhere, or puts entry data into realtime payloads.
Where secrets never appear#
Lists, reference cards, the reference picker and search, item exports (vaults cannot be exported), realtime events (they only say "something changed"), notifications, the activity of tasks, AI teammates (their tools, sandbox and prompts) and the MCP server. Reference cards show only the title and the website host. Search matches titles and hosts only, never usernames or secrets.
Every vault endpoint refuses requests made with a personal API token (REST or MCP) and tool calls of AI teammates (VAULT_APP_ONLY), and vault entries are hidden from reference cards and search in those contexts. Vaults are for people working in the TaskCrew app. The integration test apps/api/test/vault.int.spec.ts checks all of these: the raw database row, every listing and search endpoint, exports, MCP tools and the AI-teammate scope.
Who can do what#
Vaults start members-only. The API enforces this even when a client asks for a non-restricted vault. The person who creates a vault is its manager. Other people get access only when a manager adds them.
| Role | Can |
|---|---|
| Viewer | see titles, usernames and websites; reveal and copy secrets (logged) |
| Editor | everything a viewer can, plus add, change, delete and restore entries |
| Manager | everything an editor can, plus see the access log, change vault settings and manage members |
Workspace admins and project managers normally see every restricted item in their projects. For vaults that is not enough to open them. They can see that the vault exists and manage its members, but they see no entries until they add themselves. Adding themselves is visible in the member list, and everything they reveal afterwards is logged. A vault never ends up orphaned, and nobody can read it silently.
A vault in someone's personal space ("My space") can only be opened by that person, not even by admins.
When a manager turns off "Only people I add" for a vault, everyone with access to the project can open it. The vault then shows a warning banner.
Reveal, copy and the access log#
- Secrets are returned only by
POST /api/vault-entries/:id/reveal, never by a GET. A caller can ask for just one field (for example only the password when copying). Responses carryCache-Control: no-store. - Every reveal, copy, download, change, deletion and restore is written to
vault_access_logwith the person and the time. Downloading an SSH key or a file is a reveal of that part, logged as "downloaded". - 2FA codes are computed in the browser from the revealed seed. A QR code picture is read in the browser as well (jsQR); the picture is never uploaded. Vault managers see the log for the whole vault and for each entry ("Who viewed it").
- Reveals are limited to 30 per person per minute (HTTP 429).
- In the browser, revealed secrets live only in the open panel's state. They are never put in the query cache. They hide after 20 seconds, when the tab is hidden, and when the panel closes.
- Copied secrets are cleared from the clipboard after 30 seconds. This is best effort. Browsers do not let a page read the clipboard without permission, so TaskCrew clears it even if you copied something else in the meantime. If the tab is not focused at that moment, it clears the clipboard when you return. Clipboard managers in the operating system can still keep their own history.
Confirming your password (on by default)#
New vaults ask for your TaskCrew password before showing a secret. This setting is on by default for team vaults and personal vaults. It protects against the most common real-world leak: a laptop left unlocked, or a browser someone else can use. One confirmation unlocks vaults for 5 minutes, for this browser session only, so it does not get in the way. The password is checked with the same scrypt verification as sign-in. Five wrong attempts within 10 minutes pause confirmation for that person.
The unlock grant is a short-lived signed token in an httpOnly, SameSite=Strict cookie. It is signed with a key derived from JWT_SECRET but different from it, so it can never be used as a session token. It is bound to the person and to the session it was issued for. "Lock" in the vault header ends it immediately.
Managers can turn the setting off per vault (PATCH /api/items/:id/vault-settings). Editors cannot, not even through the generic item settings endpoint.
Keeping passwords fresh#
The list flags an entry as "Change it" in two cases:
- exposed: someone revealed or copied the current password and has since lost access, for example because they left the workspace or were removed from the vault. This check is always on.
- old: the password has not changed for longer than the vault's reminder period (default one year; managers can choose 90 days, 6 months, a year or never).
Passwords that are easy to guess get a "Weak" badge. Strength comes from an entropy estimate in packages/shared/src/vault.ts. It penalizes common passwords, dictionary words, repeats, sequences, keyboard runs and years. It is not zxcvbn, which is not installed. The built-in generator uses crypto.getRandomValues with rejection sampling, which avoids modulo bias. It makes random passwords (20 characters by default) or passphrases from a list of about 1,000 words.
Owners and admins see the health of every team vault in Settings → Access review → Vaults: weak passwords, passwords not changed in 30–365 days (their choice, separate from each vault's own reminder), exposed ones, and how often passwords were revealed or copied in the last 30 days. It is built from the strength tag, rotatedAt and the access log, so nothing is decrypted. Which logins are affected is shown only for vaults the admin is a member of (the same titles the vault itself shows them); other vaults show counts. Nobody's My space vault is included. API tokens, MCP and AI teammates cannot call it.
Deleting#
A deleted entry goes to the vault's trash, where editors can restore it for 30 days. After that, the next time the vault is opened, its secret is overwritten with an empty encrypted value and its title and username are kept. The access log stays. Deleting the whole vault for good removes everything.
Limits (read these)#
- The server can decrypt. This is encryption at rest, not end-to-end encryption. It protects against a leaked database or backup without the key. It does not protect against someone who controls the running server or who has both the database and
ENCRYPTION_KEY. KeepENCRYPTION_KEYout of the database host and out of backups, and rotate it as you would for connector tokens. - Anyone you add can reveal. Viewers can see secrets, because that is the point of sharing. The access log shows who did.
- Rate limits and wrong-password pauses are counted per API instance (in memory). With several instances the effective limit is multiplied by their number.
- The clipboard is cleared on a best-effort basis (see above).
- No browser extension or autofill. TaskCrew does not fill passwords into other websites. That is out of scope. Copy and paste instead. The vault form tells browser password managers not to save or fill its fields.
- Titles are plain text. When someone mentions an entry elsewhere (
@[GitHub login](secret:…)), the title is part of that text and anyone who can read the text sees it. Do not put secrets in titles. - There is no export. This is deliberate, so a vault's contents cannot leave in bulk.
Accounts & sign-in#
Links sent by email#
- Password reset ("Forgot password?"): the answer is the same whether or not the address has an account, and the email is looked up and sent after the answer. The link is a signed token with its own key (derived from
JWT_SECRET), valid for 1 hour and once: it carries the account's session version, which the reset increments, so any later password change also voids older links. Nothing is stored, so there is no token at rest to leak. The token rides in the URL fragment (/reset-password#t=…), so it never reaches web or proxy logs or theRefererheader, and the page takes it out of the address bar. A reset also confirms the email address and signs the person in. - Email confirmation: sign-up emails a link (3 days, its own key, bound to the address). Nobody is blocked while unconfirmed. The one effect: an admin's invite adds an account directly only when its email is confirmed; otherwise the invite is emailed to the address. This stops someone from registering a colleague's address first and being added in their place. Accepting an emailed invite or resetting the password also confirms the address. Accounts that existed before this feature count as confirmed.
- Invites: the link is emailed (and can still be copied, so invites work without email). Re-inviting an address reuses its pending invite; admins can resend or revoke it.
- Links are always built from the first
WEB_ORIGINentry, never from request headers. Names in subjects are reduced to one short line; everything people type is HTML-escaped in emails. Opening a link (GET) changes nothing, except the confirmation page, which confirms on open — harmless if a mail scanner does it.
Notification emails and Slack#
- Only what the person can still open. Every email, Slack message and daily summary re-checks, when it is sent, that the task is not in the Trash and that the person can still see its list (restricted lists, private projects, My space, workspace membership). Anything else is dropped silently — this also covers producers that do not check access themselves (automation and playbook messages still reach the Inbox; that in-app gap is known).
- No secrets. Messages are built from the notification's title and text, the task key and title and the workspace name only — never from its
data. Slack messages leave out comment text. Nothing is emailed to an unconfirmed address. - Slack links are passwords. A person's incoming-webhook link is stored encrypted (AES-256-GCM, like connector secrets), never returned (only its last 4 characters) and never logged (only Slack's status and error word). Only
https://hooks.slack.com/services|triggers/…links are accepted (no other host, user info or port; redirects are refused), and a test message is sent before it is saved, at most 5 per person per 10 minutes. Use a direct message or a private channel: anyone in the channel reads the messages. API tokens and MCP cannot change these settings. - Unsubscribe links (and the
List-Unsubscribeone-click header) carry a signed token with its own key, valid for a year, that can only turn notification emails off. Account emails (resets, confirmations, invites) are not affected. - Limits: at most one notification email per person every 10 minutes (a burst is one email) and one Slack message per minute (doubling after failures, up to an hour); Slack pauses after a "gone" answer or 10 failures in a row.
Push notifications and the offline copy#
The installable app gets Web Push notifications and keeps a read-only copy for when the phone is offline.
- The API only sends to push services. A device's endpoint comes from the browser, so it is checked when the device is added and again before every send: https on port 443, no user info, no IP address, and a host of the browsers' push services (
fcm.googleapis.com,*.push.services.mozilla.com,web.push.apple.com,*.notify.windows.com) orPUSH_ENDPOINT_HOSTS. Sends never follow redirects and stop after 10 seconds; endpoints are never logged. - Encrypted end to end, and little in it. Each push is encrypted for the device (RFC 8291, aes128gcm) and signed with the server's VAPID key; push services see neither. It holds the notification's title, the task's key and title, a link into the app and an id — never comment text or the notification's data — and "Hide details on the lock screen" reduces it to "New notification". It is re-checked against the person's access when it is sent, like email and chat.
- Devices end. Sign-out forgets the device (its subscription ends in the browser and on the server); a session that ended ends the browser's subscription. A password change or reset removes every device of the account (when the password is changed in Profile, that device carries on); one added before is dropped at the next send too. A device the push service calls gone (404/410) is removed, and so is one that failed 5 times in a row. At most 10 devices per person, each listed (browser and system) and removable in Profile; API tokens and MCP cannot add or read them.
- The offline copy is only what the person can open now: their open tasks, Home's lists and the tasks they opened recently on that device — no vault, pages, tables or files (file names only), checked with the same access rules on every refresh (
GET /api/me/offline-snapshot, sessions only). It lives in the browser's IndexedDB, is replaced whole on every refresh, is off in a browser tab and on in the installed app (a switch per device, forgotten on sign-out so the next person gets the default), and is wiped on sign-out, when the session ends and when switched off; a copy older than 14 days is deleted instead of shown. - The service worker caches no private data: never API answers or app pages — only the build's content-hashed files (
/_next/static/), the offline page and the icon.sw.jsis servedno-cache, and caches are versioned. Personal data and page caches are emptied on sign-out; the offline page itself stays (static, nothing of anyone), so it still opens after signing in again. The offline page puts every value in as text, never as HTML.
Ending sessions#
Sessions are signed tokens (7 days) that carry the account's session version. Resetting or changing the password increments it: every API instance hears about it at once (Postgres NOTIFY), refuses older sessions (401, and the cookie is cleared) and closes their live connections. The device that changed the password gets a fresh session and keeps working. The version is checked from a per-instance cache (at most one primary-key read per person every 30 seconds). Personal API tokens are not revoked by a reset or change, so integrations keep running; the "password changed" email asks to review them in Settings → Developers. API tokens cannot change passwords or call any other account route.
Limits#
All counted per API instance in memory (with several instances, multiply by their number), like the vault limits:
| What | Limit |
|---|---|
| Failed sign-ins | 10 per email and 30 per IP address per 15 minutes, then a pause (429), even with the right password |
| Sign-ups | 10 per IP address per hour |
| Reset emails | 3 per address per hour (over it the answer stays 202, nothing is sent) |
| Forgot / reset / confirm requests | 30 per IP address per hour |
| Confirmation emails asked again | 3 per person per hour |
| Wrong current password when changing it | 5 per person per 10 minutes |
| Invite and "you were added" emails | 50 per admin per day (over it invites are created but not emailed); one resend per invite per minute |
Keep the API behind the reverse proxy (the API trusts one proxy hop for the client address). AUTH_RATE_LIMITS=false turns these limits off and is meant for automated tests only. Without SMTP (SMTP_URL) no email is sent: the API log shows each email with its link so whoever runs the server can pass it on — treat that log as sensitive.
Single sign-on, provisioning and the workspace audit log#
For companies that sign in with Okta, Microsoft Entra ID, Google Workspace or another SAML identity provider. Owners set it up in Settings → Single sign-on & provisioning; everything below is per workspace.
Single sign-on (SAML 2.0)#
- SP-initiated only. "Sign in with SSO" (the workspace ID) →
GET /api/sso/<id>/loginstores the request and redirects to the identity provider, which posts its answer to the workspace's ACS URL. An answer nobody asked for (IdP-initiated) is refused. The request is tied to the browser that started it: a short-lived HttpOnly cookie (SameSite=None; Secure, so it comes back with the provider's cross-site POST) holds the request's random ID, and the ACS accepts only an answer to that request, then clears the cookie. Someone posting their own valid answer into another person's browser (login CSRF) gets nothing, and the other request is not used up. This needs the API on https (COOKIE_SECURE=true) or on localhost. No single logout: signing out of TaskCrew does not sign out of the identity provider, and a TaskCrew session lasts until it ends (below) even if the provider ends its own. - What an answer must be (
@node-saml/node-saml5.1.0 withxml-crypto6.3.3): the assertion signed with the certificate saved in the settings (a signed response alone is not enough; a certificate inside the message is ignored; a second certificate can be saved while the provider rotates); its Issuer the saved entity ID; its Audience this workspace's SP entity ID (so an answer for one workspace never fits another); its Recipient this workspace's ACS URL; inside its NotBefore/NotOnOrAfter times with 60 seconds of clock skew; and its InResponseTo a request of this workspace from the last 10 minutes that was never used — claimed atomically in Postgres, so a replayed answer fails, also across API instances. XML parsing never resolves external entities, exactly one assertion is accepted, and only the signed bytes are read (the signature-wrapping defences). Each case has a test with a test identity provider. A refused answer tells the person only that it could not be confirmed; an owner's test sign-in also gets a short reason (signature, issuer, audience, recipient, expired, no email), and the details go to the server log only. - Only at verified email domains. The workspace's owner configures its identity provider, which can assert any email, so an owner first proves the workspace controls a domain with a DNS TXT record (
_taskcrew-verification.<domain>=taskcrew-verification=<token>, Settings → Single sign-on & provisioning → Email domains). An assertion for an address at any other domain is refused (unverified_domain), member or not, and never creates an account; so is one whose SCIM-linked account's address is outside them (unless this workspace's single sign-on created that account). Removing a domain stops sign-ins there at once; memberships stay. - An identity provider can only sign in people the workspace already has, or create new accounts. By the email in the assertion (lower-cased): someone turned off in provisioning is always refused; an existing account signs in only when it is a member of this workspace or holds a pending invite to it (the invite is accepted, with its role); an email with no account at all gets a new one, without a password, when SCIM provisioned the person, "Create accounts on first sign-in" (JIT, on by default) is on, or an invite waits — and joins as a member. Anything else is refused ("ask an admin of the workspace for an invite"), without saying whether an account exists and without touching it. An unconfirmed account that was invited here is confirmed by the identity provider: its password is removed and its sessions end, so whoever registered the address first loses it.
- SSO sessions are confined to their workspace. Another company's identity provider could assert anyone's email, so a session it creates works only in that workspace (other workspaces answer
404, like an API token) and cannot create API tokens or workspaces, accept invites, change the password or email, or change where notifications go or the name, time zone and preferences every workspace sees (unless that workspace created the account) — and gets no live notifications from other workspaces. It lasts 12 hours by default (1–168, set per workspace). The one exception: when someone signed in with their password goes through SSO, that same session gains the workspace's proof instead (it keeps their other workspaces and then lasts the SSO length). - Require SSO can be turned on only after a test sign-in passed with the saved settings (changing the provider's details asks for a new test). Then a member's session without that workspace's proof gets
403 SSO_REQUIREDthere — REST, MCP and live rooms, all through the one access check — and the app offers "Continue with SSO". Owners keep their password (break-glass if the provider is down or misconfigured); each owner's use is logged, at most once an hour. API tokens and AI teammates keep working — review and revoke tokens in Settings → Access review. The switch is cached for 30 seconds per API instance (an access change re-reads it at once). - Limits: vaults that ask for the account password can't be unlocked by people who only sign in through SSO until they set a password with "Forgot password". A confined session can't join another workspace (sign in with a password for that). Starting a sign-in is limited to 60 per IP address per 10 minutes.
Provisioning (SCIM 2.0)#
- One token per workspace (
tcscim_…), made by an owner, shown once and stored as a SHA-256 hash; replacing it ends the old one at once; Settings shows when it was last used. It works on/api/scim/v2only, and nothing else works there (not sessions, not personal API tokens). 600 requests a minute per workspace. - Users only, no Groups: roles stay in TaskCrew. New people join as members; owners can't be turned off or deleted through SCIM (
409). A confirmed account at one of the workspace's verified email domains is linked and added at once (as an admin's invite would); a token naming anyone else links nobody; someone without an account joins at their first SSO sign-in; an unconfirmed existing account is never linked (it needs an invite). A name change reaches the account only when this workspace's single sign-on created it. - Turning someone off (
active: falseorDELETE) ends their membership at once — they are taken off its tasks, their calendar link and API tokens stop, their open pages lose the workspace. For an account this workspace's single sign-on created it also ends, like a password change, their sessions and push devices everywhere; anyone else's account is theirs, so a provisioning token can't sign them out of other workspaces. Turning them on again makes them a member again. - Removing someone only in TaskCrew while the identity provider still has them active (or JIT is on) lets them back at their next SSO sign-in: offboard in the identity provider.
Workspace audit log#
Settings → Audit log → Workspace & sign-in (owners and admins, in the app only; also as CSV): members added and removed (by invite, invite link, SSO or SCIM), role changes, invites sent, resent and cancelled, workspace renames, SSO and SCIM settings and tokens, SSO sign-ins and refusals, owners' password use while SSO is required, and API tokens created and revoked (also by an admin in the access review). Each entry keeps who, when, the person affected (name and email as they were then) and the client's IP address; entries are written in the same transaction as the change and cannot be edited or deleted in the app. Password sign-ins are not workspace events and are not listed; changes to tasks are in the Tasks tab.
Roles#
Access is rank-based: owner > admin > member > guest in a workspace, manager > editor > commenter > viewer on projects and lists, plus private projects, restricted lists and My space. Guests are below; see Guests. Custom roles are deferred: most writes check one rank through AccessService, and a permission matrix would touch every service, MCP tool and live-room check — a high risk of leaking data for little gain. Today's nearest tools are project viewers, restricted lists and private projects. A read-only "Auditor" preset (all team projects plus the audit log) is the planned follow-up, designed with a full review of every write path.
An AI teammate on a task follows the rank on the task's list: viewers and commenters watch its runs and see its schedules; starting, stopping or retrying a run, adding, changing or deleting a schedule, and approving or rejecting an action it asks about need Edit (403 otherwise).
Guests#
A guest is someone from outside the team, invited by email to specific projects or items with View, Comment or Edit, never Manager.
- Default-deny in two places.
AccessService.workspace()keepsMEMBERas its minimum. Every workspace-wide route (about 85 call sites) therefore refuses a guest with 403GUEST_NOT_ALLOWED; only the resolvers of one project, item, task or page, and the workspace live room ("something changed" signals only), let a guest in.visible()refuses guests unless a caller about something shared opts in ({ guests: true }: the tree, a project, references, canvas embeds, a list export). In SQL it loads only the guest's own shares. - One rule for what a guest opens.
effectiveProjectRoleandeffectiveItemRolehold it, and every batched check (notifications, the access clean-up, pickers, live rooms) follows it. A guest opens a project or item only through their own share, never because it is open to everyone. They have no My space. Their role is capped at Edit. They open only task lists, wikis, tables, canvases and notes: never vaults (even when added), forms, playbooks, goals or dashboards. - View, Comment, Edit. A member's Viewer reads and comments (as before). A guest's View only reads, Comment (
COMMENTER) adds comments, replies and reactions, and Edit changes the work. A guest never manages, shares, makes public links, adds items or imports, changes a list's states, labels or fields, or touches automations, recurring rules, AI teammates (runs, schedules, assigning one, @mentions that start one), templates or time tracking. - People. A guest's member directory is only the people who can open something shared with them. Person cells in a shared table resolve only among that table's people. Guests are never in team chat (no channels, DMs or chat mentions).
- No tokens. Guests cannot create API tokens, and a token of someone made a guest stops working. They are exempt from "Require SSO": they are outside the company's identity provider and sign in with a password.
- Removing access is immediate. Taking a share away or removing the guest re-checks their live rooms at once and takes them off tasks they can no longer open. Settings → Sharing lists every guest with what they can open and when they were last seen (
lastSeenAt, written at most every 15 minutes when they open the app). Guests are free: there is no seat count.
Data residency#
TaskCrew is self-hosted: data stays where you run it. Pick the region of Postgres (all records, encrypted secrets and the audit logs), the upload storage (UPLOAD_DIR), the SMTP provider (SMTP_URL), the AI provider used by AI teammates (OPENROUTER_BASE_URL and the models you choose) and the connectors you turn on. Push notifications pass through the browsers' push services, encrypted end to end. There is no multi-region routing within one installation: run one per region if you need that.
SOC 2 and ISO 27001#
SOC 2 reports and ISO 27001 certificates are audits of the organisation that runs TaskCrew (people, processes, hosting) by an accredited firm; the software itself is not certified. The controls TaskCrew gives you, and the evidence:
| Control (SOC 2 / ISO 27001:2022) | In TaskCrew | Evidence |
|---|---|---|
| Logical access, least privilege (CC6.1, CC6.3 / A.5.15, A.5.18, A.8.3) | Workspace and project roles, private projects, restricted lists, My space | Settings → Access review (CSV) |
| Authentication (CC6.1 / A.5.17, A.8.5) | SAML SSO and Require SSO, scrypt password hashes, sign-in rate limits, session revocation | SSO settings; audit log "Sign-in & single sign-on" |
| Provisioning and deprovisioning (CC6.2, CC6.3 / A.5.16, A.5.18) | SCIM, invites, immediate removal (tasks, tokens, sessions, push devices) | Audit log "Members & invites" and "Provisioning (SCIM)" (CSV) |
| Logging and monitoring (CC7.2 / A.8.15) | Task audit log, workspace audit log, every vault reveal logged | Audit log CSVs; vault access logs |
| Encryption (CC6.7 / A.8.24) | Vault and connector secrets AES-256-GCM at rest; tokens stored as hashes | This document; your TLS and disk encryption |
| Automation and API access (CC6.1 / A.8.2) | Personal API tokens confined to one workspace and refused on account and admin routes; AI teammates never see vault secrets | Access review "Tokens & links" (CSV); audit log "API tokens" |
| Change management, backups, incident response, vendors, people (CC8, CC9 / A.5.24–A.5.30, A.6) | Organisational: yours as the operator | Your policies and records |
File storage and thumbnails#
- Storage: local disk (
UPLOAD_DIR) or an S3-compatible bucket (STORAGE_DRIVER=s3). The bucket stays private and there are no presigned links: every file still leaves through the API, after the access check and with the same headers (nosniff, a sandboxing CSP except for PDFs,attachmentfor anything but safe images, PDFs and plain text), byte ranges included. Keys are made by the server (<workspace>/<uuid>-<safe name>), never from a file name. The S3 credentials are read from the API's and the runner's environment only: never stored, sent to the browser, logged (storage errors carry the HTTP status only) or given to AI teammates' sandboxes (their environment is an explicit allow-list; they get bytes through the tool broker as before). - Thumbnails (media library) are made with sharp/libvips, lazily, only for PNG, JPEG, GIF (first frame) and WebP whose first bytes really are one of them (an SVG or a page in disguise never gets one); at most 40 million pixels (a "100k × 100k" header is refused before decoding), 10 seconds, two at a time per API instance (a request waits 5 seconds, else 503), metadata stripped, always our own WebP of at most 400 px.
THUMBNAILS=offturns them off. - File versions and the file viewer: file downloads accept one byte range (anything else
416) and keep the sandboxed headers; PDFs are drawn by pdf.js on a canvas only (no text or annotation layer, so no links; pdf.js 6 has no eval path); SVGs show only through<img>.
Reading attached files (search)#
Search reads the words inside task attachments (PDF, Word, Excel, PowerPoint, text). These files come from anyone who can upload to a task, including AI teammates and connectors, so they are treated as hostile:
- Never on a request. An upload only queues the file (a database trigger, whoever wrote it); a background worker reads it later, and a failure is recorded on the file's row (
failedwithtimeout,encrypted,corruptortoo_large) without affecting the upload or anything else. - One worker thread per file, with at most 256 MB of memory (more ends the thread:
too_large), stopped after 30 seconds, its console output dropped. Files over 30 MB are not read; text files only for their first 1 MB. - PDF: pdf.js 6 (
pdfjs-dist, legacy build, well past the CVE-2024-4367 fix in 4.2.67) extracts text only — no rendering, no fonts loaded, no WebAssembly or scripts, at most 300 pages. Its optional native canvas is never installed. Encrypted PDFs are not opened. - Word, Excel, PowerPoint: only
word/document.xml, the slides and the shared/inline strings are taken out of the zip, inflated as a stream that stops at 30 MB in all or 2,000 entries (zip bombs), and read with patterns that never go back over the text (no XML parser, no external entities). Old binary formats are not read. - The file says what it is: a PDF must start with
%PDF-, an Office file must be a zip, a text file must not hold NUL bytes; otherwise it isskipped, as are images, emails and other kinds. - At most 200,000 characters are kept per file. They only ever leave the database as a short snippet in search results (the app and the MCP
searchtool) for people who can open the task — the same rule as the file itself — never in notifications, emails or exports. Vault entries are never read or indexed.
Maps (Map view, "Pick on map")#
Maps are drawn in the browser with Leaflet from map tiles (small images) fetched from a tile server the operator chooses; the browser loads Leaflet only when a map opens.
- What the tile server sees: opening a map sends the viewer's IP address, the browser's usual headers (including the referrer, the app's address — tile servers such as OpenStreetMap's require it) and which area and zoom they look at. No task, title, field value or person is ever sent: pins are drawn in the browser on top of the tiles.
- Which server:
TASKCREW_MAP_TILE_URL(web), anhttp(s)URL template with{z},{x}and{y}, read when a page is served; people can never choose a tile server. Default: OpenStreetMap's standard tiles, credited with "© OpenStreetMap contributors" (TASKCREW_MAP_ATTRIBUTION, plain text, overrides it;TASKCREW_MAP_MAX_ZOOM, 1–22, default 19). OpenStreetMap's tiles are for light use (see its tile usage policy: no bulk downloads, credit shown): a small team is fine, a busy server should run its own tile server or use a provider (an API key in the URL is visible to every viewer's browser). An unusable URL turns maps off and logs a warning. - Turning maps off:
TASKCREW_MAP_TILE_URL=offhides the Map view and "Pick on map" everywhere; Location fields still work (typed coordinates and pasted map links) and "Open in maps" still links to openstreetmap.org. - Content Security Policy: the web app sets none of its own. A proxy that adds one must allow the tile host in
img-src(e.g.img-src 'self' data: https://tile.openstreetmap.org). - Map links are read in the browser, by patterns, never fetched (short links such as maps.app.goo.gl cannot be read for that reason), and nothing is looked up by address (no geocoding). The API accepts only
{ lat, lng, label? }with latitude −90…90, longitude −180…180 and a label of up to 200 characters; labels are shown as text, never HTML, and "Open in maps" links are built from the two numbers only.
Public links#
- A link
/s/<token>holds 256 random bits (tcs_…); the API finds it by a SHA-256 hash and keeps it encrypted (AES-GCM,ENCRYPTION_KEY) only so the item's managers can copy it again. Links expire after 7, 30 (default) or 90 days unless the sharer chose "until revoked"; managers, admins and the link's creator revoke them at once; owners and admins can turn every link of the workspace off (Settings → Sharing, or Access review → Tokens & links), which also stops new ones. - Whose access: everything is computed as the link's creator, live, with their access as it is now and confined to the link's workspace: if they leave, lose the item, or the item, project or page is archived, the link stops working. A dashboard never shows more than its creator could see when they shared it (lists made later stay out, and their My space never). The view (filters, sort, columns, dashboard dates and filters) and a dashboard's widgets are frozen in the link: a widget an editor adds or widens later never reaches it, since it would read with the creator's access. "Require SSO" does not apply: the visitor is not a member, and the creator's own sign-in is not used.
- What leaves the server: explicit fields only — task titles, status (AI teammates' states read "In progress" and "Ready for review"), the columns chosen (never person fields), dashboard numbers with group keys stripped of ids, page text with references as plain titles, mentions as "a teammate", live embeds as "not shown on public pages". Never descriptions, comments, files, emails, task keys or workspace, project, user or task ids. People and AI teammates read as "Person 1", "AI teammate 1" unless the sharer turned names on. A list's filters run on the server (the same code as the app), so rows the view hides never leave it.
- Images of a shared page load through the link, only when a shared page shows them and the creator can open them, with the sandboxed headers of every stored file.
- Every answer is
no-store,noindex, nofollowandno-referrer(the web page too, with arobotsmeta). Unknown links count against the network (300 an hour), views against the link (600 an hour); a dashboard is computed at most once a minute per link. The public page has no sidebar, search, live connection or links into the app.
Forms and intake#
Forms take text and files from people outside the team. There is no review queue: every response is a row of the form's own Responses table at once (and, when the form asks, a task in a list of its project). That table is a normal table with its own access: it is made in the form's project, inside the form (hidden from the sidebar, in the form's Responses tab, archived and deleted with the form) and follows the form's sharing — made private (with the form's people and roles) or open again with the form, and a person added to the form, given another role or taken off it gets the same on the table, in the same transaction — and everyone who can open it sees the responses (guests too, when it is shared with them or its project is). Responses there are searched, exported and readable by AI teammates with access like any row, and a table's "a row is added" automations run for them — the spam brakes below are what keeps junk out, and deleting a row removes it with its files. AI actions outside TaskCrew still wait for approval when "ask before acting" is on.
The public page#
- The link
/f/<link>holds 128 random bits; "Get a new link" ends the old one at once. It only lets people send a request. The page and its API answerCache-Control: no-store,Referrer-Policy: no-referrerandX-Robots-Tag: noindex. - It shows only the form's name, description and questions (label, help, type, options, rules) — never the workspace, project, Responses table, task list, members or internal ids. Sending answers only
{ ok, confirmation }: nobody filling in a form ever sees other responses. Closed, archived, unknown or rotated links (or a Responses table that is archived) all answer the same "This form isn't taking responses right now". - Members-only forms need a signed-in member of the workspace (no project access needed; their name is recorded in a "Submitted by" column — they still can't open the table unless they could anyway). API tokens cannot fill in forms.
- The form's columns are kept by the form: a form editor who can't open the Responses table still edits the questions (only column names, types and options change) and never sees a row.
Spam brakes (no third-party CAPTCHA)#
- A hidden field people never see (bots fill it in: the response is answered as sent and dropped); a signed load token bound to the link (it names no internal id; a new link ends it) — sent sooner than 3 seconds after the page loaded = dropped the same way, older than a day = "reload the page"; and, when the form asks it, a small sum ("What is 4 + 3?") whose answer the token carries as a keyed hash.
- Limits checked before the body is read (per API instance, off only with
AUTH_RATE_LIMITS=false): 5 responses per IP address per form per 10 minutes, 20 per IP address per hour, 300 per form per hour. A response must declare its size (at most 5 files × the largest allowed file + 3 MB) and at most 4 responses with files are read at once per instance (503otherwise). A Responses table holds at most 50,000 rows; a full one closes the form.
Files#
- At most 5 per response, each within the form's limit (1–25 MB, 10 by default, never above
MAX_UPLOAD_MB). Programs, scripts and pages are refused by name (.exe .bat .cmd .msi .dll .ps1 .vbs .js .jar .sh .html .htm .svg…) and by their first bytes (Windows/Linux/macOS programs,#!scripts, HTML/SVG/XML). Images and PDFs are stored with the type their content shows; a file that claims to be an image but is not is stored as a plain download. - They are kept as Files & media files of the Responses table (only people who can open it open them, through the same sandboxed,
nosnifffile route as every library file; images and PDFs only when their content is one) and shown in the response'sfilecell, which nobody can change by hand. Deleting the row, the column or the table removes them (a table deleted for good: only once that change is saved). There is no malware scan: treat these files like email attachments from strangers.
Text from outside the team#
Answers are kept as sent: table cells show them as plain text. In a task made from a response, mention and reference tokens become plain words and every character that could start Markdown is escaped, so a response can never add a mention, a reference, an image (a tracking pixel), a disguised link or HTML (the task's one live reference is the link to its row, added by TaskCrew). Titles are one line without control characters. Bare web addresses may still show as links (exactly as typed). A response never starts an AI teammate by itself (only an automation someone set up can).
Copies to the sender (off by default)#
A members-only form emails the member (confirmed address only) a copy of their answers. A public form sends a receipt only — the form's thank-you message, never the answers — because anyone can type anyone's address: a form must not relay text. At most 3 receipts per address per hour, marked Auto-Submitted: auto-replied.
Emailed responses#
Each form can have a secret address req-<20 random characters>@<INBOUND_EMAIL_DOMAIN>; "Get a new address" ends the old one. An email becomes a response with columns of its own (Email from, subject, message, attachments). The sender is never treated as a member: a task made from it says "from x@y (not verified)".
- The webhook
POST /api/inbound/email/mimetakes the raw message and checks HTTP Basic auth againstINBOUND_EMAIL_SECRETin constant time before reading anything (401;404when the server takes no email;413aboveINBOUND_EMAIL_MAX_MB). Anything not for an open form is answered200with nothing stored, so providers do not retry it. - Loops: the server never replies to inbound mail. Auto-replies (
Auto-Submittedother thanno,X-Autoreply,X-Autorespond), bulk and list mail (Precedence), bounces (emptyReturn-Path, MAILER-DAEMON, postmaster) and mail from our ownMAIL_FROMare dropped. 60 emails per hour per address, 20 per hour per sender. A provider's retry of the sameMessage-IDis not added twice. - Allowed senders (optional, up to 50 addresses or
@domains) are a filter, not proof of identity; with a list set, a DMARC failure in the provider's firstAuthentication-Resultsheader drops the email too. - What is kept: the subject, the text part (HTML made plain when there is none, at most 10,000 characters), up to 5 attachments under the same file rules; inline images and refused files are listed at the end of the message ("Left out of the email"). A task made from it is titled by the subject.
Setting it up (whoever runs TaskCrew): point the MX record of a subdomain (in.example.com) at a mail provider, add a catch-all route *@in.example.com that POSTs the raw message to https://inbound:<INBOUND_EMAIL_SECRET>@api.example.com/api/inbound/email/mime (Mailgun: "store and notify" to a URL ending in mime; SendGrid Inbound Parse: "POST the raw, full MIME message"), and set INBOUND_EMAIL_DOMAIN and INBOUND_EMAIL_SECRET (16+ random characters). A Cloudflare Email Worker works too:
export default {
async email(message, env) {
const raw = await new Response(message.raw).arrayBuffer();
await fetch(`https://api.example.com/api/inbound/email/mime?to=${encodeURIComponent(message.to)}`, {
method: 'POST',
headers: { 'Content-Type': 'message/rfc822', Authorization: `Basic ${btoa(`inbound:${env.INBOUND_EMAIL_SECRET}`)}` },
body: raw,
});
},
};
To try it locally, curl -u inbound:$INBOUND_EMAIL_SECRET -H 'Content-Type: message/rfc822' --data-binary @brief.eml http://localhost:4000/api/inbound/email/mime. Postmark's JSON format is not supported.
AI project builder#
"New project → Describe it" sends only what the person types (up to 1,000 characters) and static catalogues (state categories, field types, the built-in playbooks, dashboards and AI teammate templates) to the model through OpenRouter. No workspace data goes with it: no project, list, task or member names. The tab says so under the box.
- Nothing is stored by the plan call; the plan lives in the browser until Create project.
- Model output is untrusted. The plan is parsed with
projectPlanSchema(caps on every part; 400 / a fix request to the model otherwise) and cleaned: names are one line without control or direction characters, mention and reference tokens become plain words (nobody is pinged), and Markdown (task descriptions, wiki pages) goes throughsafeMarkdown(images become plain links, links keep http(s) and mailto only). Playbooks, dashboards and AI teammates are built-in keys only: the model never writes instructions for a teammate, and the Workspace assistant is never suggested. Suggested teammates ask before outside actions, like any new one. - Create checks and cleans the plan again (it comes back from the browser), then goes through the template path in one transaction with the creator's own access (any member creates projects). Teammates already in the workspace (same template) are not created again.
- Spend brakes: 5 plans per person per 10 minutes and 30 per workspace per day (per API instance), 429 with Retry-After; one call with at most 3 steps, 8,000 output tokens and 90 seconds. API tokens can't plan. Each plan's token use is logged. Without
OPENROUTER_API_KEYthe API answers 503AI_NOT_CONFIGUREDand the tab points to the templates.
AI usage and the monthly limit#
Settings → AI usage shows amounts only — spend, tokens and runs by AI teammate, project, person and month — never task titles, instructions or output. Only owners and admins can read it or set the limit (403 for members, 404 outside the workspace); everyone else can only ask whether AI is paused (/ai-usage/status, no amounts). Every personal space is folded into one "My spaces" row, never whose. The breakdown covers the whole workspace — restricted lists, private projects, the Trash and archived projects included — because the limit counts everything that was spent.
- API tokens can't change the limit (
403): an agent or a script holding a token never lifts its own spending limit. AI teammates have no tool for it either. - One check, before anything is queued:
createAgentRun(every run, from the API and the runner) and the two model calls that are not runs (agent builder chat, AI project builder) read this month's running total, which the runner keeps in the same transaction that records a run's usage. A run already under way is never stopped by the limit, so a month can end a little over it. - Alerts go to owners and admins only, once per threshold a month, claimed by a conditional update so several API instances never send one twice. Only they see the alert's row in their notification settings.
- Money amounts for AI are for owners and admins only, everywhere. All projects' AI spend (
aiCostUsd,aiRunCount) is left out of everyone else's answer, and so is a run'susage.costUsd— a task's runs, a run, an AI teammate's history, Run/Stop/Retry, the model calls on its timeline — while its tokens stay. Realtime run events reach everyone on the task, so they never carry the cost. The paused status (paused,resumesOn) is the only AI-limit fact members get.
These pages are built from the same docs that ship with the code, so they change when the product does. See what changed in the changelog