Thingtime is a powerful platform for storing and sharing information of all kinds. Whether you want to keep track of your personal notes, collaborate on a project with your team, or build a new app that relies on rich data, Thingtime has you covered.
With Thingtime, you can create and share any abstract data structure you want, or store any practical piece of information and share it for people and machines to use equally. Thingtime is not only a platform, but also an ecosystem that empowers developers and users alike to build, share, and utilize all kinds of data and knowledge.
At Thingtime, we believe that data and knowledge should be open, accessible, and empowering. We are building Thingtime to make this vision a reality. Join us and start exploring the limitless possibilities of data!
Repository-wide AI guidance lives in the single canonical AI_ALL.md.
AGENTS.md and CLAUDE.md are relative symlinks to that file so Codex,
Claude, and other compatible tools read the same instructions. Update
AI_ALL.md only; keep both symlinks intact.
Thingtime keeps executable CI/CD behavior on the long-lived, protected
github-actions branch. Product branches (main, develop, feature branches,
and promotion branches) retain only seven small event listeners in
.github/workflows/: GitHub must be able to discover a workflow file on the
ref/default branch that receives a native push, pull_request_target,
schedule, repository_dispatch, or workflow_dispatch event. Each listener
contains triggers, caller permissions, and typed inputs only; its sole job calls
the matching reusable workflow at
lopugit/thingtime/.github/workflows/<name>.yml@github-actions.
All runner selection, shell commands, third-party actions, AI/model routing,
Git operations, Graphify refreshes, and workflow scripts live only on
github-actions. The product branches intentionally contain no .github/actions
or .github/scripts behavior. remix/scripts/workflow-caller-contract.mjs
fails if executable behavior leaks back into a listener or one stops pinning the
control-plane ref.
Protect github-actions with a ruleset: require pull-request review for changes,
block force pushes and deletion, and restrict direct updates. A push to that
branch runs its own control-plane contract CI. Updating the implementation no
longer requires separately merging the same behavior into develop and main;
the thin listeners on both branches call the same reviewed revision immediately.
The Admin β CI Control dashboard adds the external observation/operation layer:
signed GitHub and Vercel webhooks project repositories, features/stacks,
branches, pull requests, Actions runs, deployments, previews, audited dispatches,
and append-only status history into protected Things. The GitHub App is also
used for explicit reconciliation, allowlisted workflow dispatch, and ephemeral
self-hosted runner registration. Native listeners remain the automatic trigger
path, so a webhook outage cannot silently turn off conflict resolution or CI;
the dashboard makes drift and stale delivery state visible. Administrator
dispatches always enter the reviewed github-actions implementation; neither
the UI nor API can load workflow YAML from an arbitrary feature branch.
For supported automations, an administrator can choose GitHub Actions or Vercel Sandbox independently. The native listener first runs a tiny provider router on GitHub. A GitHub selection continues normally. A Vercel selection starts a durable Vercel Workflow, creates an ephemeral Vercel Sandbox, registers that Sandbox as a uniquely labelled GitHub self-hosted runner, and dispatches the same protected reusable workflow back onto that runner. GitHub therefore remains the workflow/event control plane while the expensive compute runs on Vercel. If the signed router, App, Workflow, or Sandbox is unavailable, the trigger records the fallback and continues on GitHub-hosted compute instead of dropping the automation. Web CI remains GitHub-only while its API test job requires a Docker MongoDB service; Electron release remains GitHub-only because it needs native platform runners.
Configure the server-side integration with private environment variables only
(never PUBLIC_*):
THINGTIME_GITHUB_REPOSITORY="owner/repository"
THINGTIME_GITHUB_APP_ID="123456"
THINGTIME_GITHUB_APP_INSTALLATION_ID="12345678"
THINGTIME_GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
THINGTIME_GITHUB_WEBHOOK_SECRET="replace-with-a-long-random-secret"
THINGTIME_VERCEL_WEBHOOK_SECRET="secret-returned-when-the-webhook-is-created"
THINGTIME_CI_ROUTER_SECRET="another-independent-long-random-secret"Create a repository-installed GitHub App with repository metadata read,
Actions read/write (workflow dispatch and run/job observation), Administration
read/write (short-lived self-hosted runner registration and deletion), Contents
read (branches), Pull requests read, and Deployments read. Install it only on
the intended repository. Subscribe its webhook to push, branch create/delete,
pull request, workflow run, workflow job, deployment, and deployment status
events, using:
https://<your-thingtime-origin>/api/v1/integrations/github/webhook
Create a project-scoped Vercel webhook for deployment created/ready/error/ canceled/deleted events at:
https://<your-thingtime-origin>/api/v1/integrations/vercel/webhook
The signed compute-provider route is:
https://<your-thingtime-origin>/api/v1/integrations/ci/route
Store each secret directly in the deployment environment. Also add the same
THINGTIME_CI_ROUTER_SECRET as a GitHub Actions repository secret and set the
repository variable THINGTIME_CI_ROUTER_URL to the stable route above. The
router secret is deliberately independent of both webhook secrets. Vercel's
system-provided OIDC identity authenticates Sandbox creation in deployed
functions; local/non-Vercel execution may instead provide VERCEL_TOKEN,
VERCEL_PROJECT_ID, and VERCEL_TEAM_ID. Set
WORKFLOW_SEQUENTIAL_REPLAYS=1 in Vercel for deterministic durable-workflow
replay. The Admin API reports only whether an integration is configured; it
never returns credentials. Admin reports Vercel runner ready only when the
GitHub App credentials, provider-router secret, and Vercel runtime identity are
all available; its API refuses to select Vercel before that complete capability
is ready. An already-saved Vercel policy still fails over safely to GitHub if a
dependency later disappears.
After deployment and App installation, create both provider webhooks and click Admin β CI Control β Reconcile once. Reconcile imports existing branches, open PRs, Actions runs, deployments, and previews; subsequent webhooks keep the projection current. Until that first successful reconcile, an empty dashboard with zero counts is expected.
https://www.indiegogo.com/projects/thingtime-a-gui-for-the-internet/coming_soon
https://www.gofundme.com/f/thingtime
Thingtime has two deliberately separate conflict workflows:
- Resolve PR conflicts (AI) merges a PR's base branch into its head branch.
- Rebase PRs and stacks (AI) rebases the PR head and, when the PR is part of a stack, continues from the stack root toward its leaves.
Both workflows cover every same-repository PR regardless of its base branch.
The merge workflow listens to pushes on "**" and checks open PRs both
targeting and originating from the pushed branch. A staggered twice-hourly
all-PR sweep catches conflicts whose original event was missed or ran from an
older branch without the latest workflow. Their ownership is intentionally
disjoint: standalone merge conflicts go to the merge workflow; standalone PRs
that merge cleanly but cannot rebase, plus stack members whose current history
needs a merge or rebase update, go to the rebase workflow. Adding
no-ai-rebase opts a merge-conflicting stack member back into the merge-based
resolver instead.
The rebase workflow covers the case GitHub reports as mergeable: true but
rebaseable: false: a plain merge needs no help, yet replaying the branch's
commits onto its base stops at a conflict. It automatically scans same-repo
PRs after branch pushes and PR opened/reopened events, with a scheduled
all-PR scan as a backstop because GitHub emits no dedicated event when its
Rebase stack button fails. A standalone PR is replayed onto its base; a
detected stack is rebased root-to-leaf, so each child is replayed onto the
rewritten parent rather than onto the parent's old SHA.
To run it directly, open Actions β Rebase PRs and stacks (AI) β Run workflow on the default branch, enter the PR number, and leave cascading enabled when the PR has children. Leaving the number blank scans all open same-repository PRs. Manual dispatch is also the recovery path after reviewing a paused run.
Resolve PR conflicts (AI) has the same manual convention: enter a base
branch to scan only that base, or leave it blank to scan every open eligible
PR. Broad scans are API-only detectors; they hand off one trusted
default-branch run per conflicted base, so unrelated bases do not share one AI
job. If a run fails while the same eligible snapshot is still live, it adds
ai-merge-paused so the scheduled sweep cannot repeatedly spend AI budget.
The hold is bound to the exact owner, refs, SHAs, and topology recorded in a
bot-only hidden marker: a changed snapshot is eligible again automatically,
while the same snapshot requires review and a named-base manual retry.
The merge workflow also snapshots the exact live head and base SHAs, repeats its PR/ref/label/stack/protection checks immediately before publication, and uses an exact head lease. If either branch moves while Claude is working, the resolved merge is discarded rather than overwriting the newer work.
Detection is patient and audible: GitHub computes a PR's mergeability lazily
after its base moves and verdicts can take minutes to settle, so the merge
detector re-queries until every scanned PR has a verdict (time-budgeted via
MERGEABLE_POLL_SECONDS, default 500 seconds β a little over eight minutes)
instead of sampling once at push time. When it must leave a conflicted-looking PR alone β a fork PR it
cannot push to, or a verdict that never settled β it upserts one status
comment on the PR saying exactly that, so a silent PR means "nothing needed
doing", never "nobody looked". Conflicts that are handed off announce
themselves through the resolve job's "Auto-resolve running" comment.
Rebase PRs and stacks (AI) rewrites PR history, so its force push has stricter boundaries:
- It operates only on branches in this repository. Fork PRs, the repository's default branch, and protected branches are refused.
- Claude receives only regular copies of the exact files stopped in conflict, inside a repo-less scratch directory. It never sees the real checkout, Git metadata, action implementation, or push credentials, and it has only read/edit/write file toolsβno shell, Git, search, or network tools. Code loaded from the exact trusted default-branch commit independently validates the scratch files, conflict set, index, and completed rebase before any push.
- Nothing is pushed until the complete rebase succeeds. The final update uses
an exact
--force-with-leaseagainst the head SHA inspected at the start, so a concurrent human or bot push makes the run fail instead of being erased. - Add
no-ai-rebaseto opt a PR out of automatic rebasing. A failed automatic run addsai-rebase-pausedfor that exact owner/ref/SHA/topology snapshot, preventing a retry loop while the failure is reviewed. A changed snapshot or resolver owner invalidates the hold automatically; retry the unchanged snapshot with a deliberate manual PR-number run.ai-rebase-in-progressis the only cross-workflow mutex. Pause labels do not decide ownership: a queued retry re-proves the exact refs and owner before clearing its specific stale pause. Publication requires pauses to be absent, and post-push cleanup preserves any fresh hold created for the new snapshot. An orphanedai-rebase-in-progresslock is recovered after 90 minutes, while paused, active, or not-yet-computed parentsβand protected or opted-out parents that still need a rewriteβkeep stacked children from running ahead. - A rewrite authenticated by
GITHUB_TOKENexplicitly dispatches Web CI against the new branch SHA when the final PR diff touchesremix/or its CI workflow, because token-authored pushes do not create ordinary Actions runs.
For a fork of Thingtime, enable Settings β Actions β General β Workflow permissions β Read and write permissions, then add one of these repository Actions secrets:
ANTHROPIC_API_KEY, orCLAUDE_CODE_OAUTH_TOKEN(created by the Claude CLI GitHub App setup).
CONFLICT_RESOLVER_PAT is optional. Add it only if the resolver must rewrite a
branch whose rebase changes files under .github/workflows/; the token needs
repository contents access plus permission to update workflows. Keep all
tokens in Actions secrets, scope them to the fork, and never put them in an
environment file or commit. Automatic runs still skip PRs originating from
another repository; the contributor's fork must run its own trusted workflow
if it wants equivalent automation.
Thingtime can run with mostly public configuration, but a few integrations need private environment variables in local development or on Vercel.
The web app lives in remix/ for historical path compatibility, but it now
runs as a React Router non-framework Vite client with Nitro API/server routes.
Vite serves the browser app on port 9999 and proxies /api to the Nitro dev
server on port 10000 when local backend env is configured. If the local
MongoDB/auth env is absent, the dev proxy sends /api requests to
https://thingtime.com instead so a fresh clone can still log in, create
service-account tokens, and use the production-backed API without copying any
private .env files.
Local development URLs on Lopu's Mac:
- Local:
http://localhost:9999 - Tailnet/Funnel:
https://lopus-macbook-pro-2.tail9606f9.ts.net:9999
The Tailnet/Funnel mapping for Thingtime should proxy
lopus-macbook-pro-2.tail9606f9.ts.net:9999 to 127.0.0.1:9999. Vite's
server.allowedHosts includes lopus-macbook-pro-2.tail9606f9.ts.net so this
host does not trip Vite's blocked-host protection.
Install and run from the app directory:
cd remix
corepack pnpm install
corepack pnpm run devFor a fresh clone or linked worktree, the equivalent repository-root bootstrap is:
npm run worktree-setupThe Remix dev, build, and lint entry points run the same dependency check
automatically. It validates every direct dependency link and uses pnpm's shared
store to repair missing or stale links, so node_modules never needs to be
copied from another checkout.
From the repository root, npm run web-pms starts or restarts the PM2-managed
dev app tt-nitro-react-router-9999. The older npm run remix-pms command is
kept as a compatibility alias.
Local branch metadata is managed automatically by remix/scripts/pre-dev.sh.
That script writes the untracked, generated remix/.env.auto; do not edit that
generated block by hand. On Vercel no file is involved: the branch comes from
the VERCEL_GIT_COMMIT_REF system env var at build and runtime.
The local dev launcher loads remix/.env, remix/.env.local, and
remix/.env.auto before spawning Nitro and Vite, so ignored private values like
MongoDB credentials are available to local API status checks without committing
secrets. These files are optional for normal app usage; set them only when you
want to override the default production-backed API fallback or run the backend
self-sufficiently against your own services.
Build and verify the repository-root Vercel output with:
npm run build:vercelThe root vercel.json deliberately installs only remix/; it never runs the
legacy repository-level postinstall. The build runs the existing Remix Vite +
Nitro pipeline, validates remix/.vercel/output, then stages and revalidates it
at the repository-root .vercel/output expected by Vercel's Build Output API.
In the Vercel project, set Root Directory to the repository root (clear the
old remix value), use the Other framework preset, and clear dashboard
overrides for Build Command, Install Command, Output Directory, and Ignored
Build Step so the tracked root config is authoritative. The root config also
sets outputDirectory: null: the build emits .vercel/output itself. The
product config disables Git deployments for the exact github-actions branch;
the thin control-plane branch carries its own root config with all Git
deployments disabled, so branches created from it never try to build an absent
app.
The desktop shell lives in electron/ and packages the same remix/ web app
with Electron. It builds the Vite client and Nitro server with
NITRO_PRESET=node_server, stages the output in electron/dist/web, then
launches the bundled Nitro server on 127.0.0.1 inside the Electron app.
Build the unpacked desktop app from the repository root with:
pnpm --dir electron install
npm run build-electronFor local desktop smoke testing:
pnpm --dir electron devThe local Electron shell reads remix/.env, remix/.env.local, and
remix/.env.auto before starting Nitro. Keep real MongoDB, auth, Vercel, and AI
tokens in ignored env files or the launch environment only; commit placeholder
examples in docs, not secrets.
Every registered Thingtime API endpoint exposes a JSON documentation endpoint
by appending -docs to the API path. The docs endpoint accepts both GET and
POST so sandboxed tools can discover the contract without caring which method
the real endpoint uses:
curl http://localhost:9999/api/v1/auth/service-account-docs
curl -X POST http://localhost:9999/api/v1/auth/service-account-docs -d '{}'Each response includes the original endpoint, accepted methods, auth notes,
step-by-step usage, payload and response examples, and generated curl, wget,
Node.js, Python, and Ruby snippets. The browser reference lives at
/docs/api, and the docs smoke tests live in the /tests page under the
Docs group.
Schemas are optional scaffolding, not a cage. Two open surfaces on every thing:
extendedβ everythingsdoc carries a schema-freeextendedproperty that accepts any JSON structure (512KB/doc cap). Thingtime wraps it in the platform envelope (share ids,tt:ACLs, timestamps) but never validates, structure-indexes, or interprets it. Replace-on-write semantics: sendextendedto swap the whole value,nullto clear it, omit it to leave it untouched (deep-merging arbitrary JSON is ambiguous, so we never do). It is not structured-searchable β/searchfield conditions can't target it β though its string content is indexed by the collection's wildcard text index like any field (so keep secrets out of it). One reserved key:tt:textLanguage(the text index's language override).- Schema-less crystals β
thingtimeis optional on create: a barePOST /api/v1/things { crystal: { any: 'shape' } }defaults tothingtime: ["data"], the bounded free-form crystal, so external apps can store structured data without declaring a schema first β and it stays searchable by real datatypes on/search.
Together they make Thingtime an open datastore: schema'd crystals get
validation and typed search, extended carries whatever else your app needs
on the same document. Docs: /docs/api β things.
MongoDB powers the app status checks and database-backed API routes. Local
development does not require MongoDB env by default: when
MONGODB_CONNECTION_STRING is missing, Vite and Nitro forward same-origin API
requests to https://thingtime.com with the same method, path, query, headers,
cookies, and payload. Upstream auth cookies are rewritten for local HTTP so
zero-env localhost login can persist through the proxy.
Set these variables only when you want this checkout or deployment to serve API requests from its own MongoDB instead of falling back to Thingtime production:
MONGODB_CONNECTION_STRING="mongodb+srv://<user>:<db_password>@<cluster>/<database>?retryWrites=true&w=majority"
MONGO_PASS="<password>"MONGO_PASS is only required when MONGODB_CONNECTION_STRING contains the
literal <db_password> placeholder. The app substitutes MONGO_PASS into that
placeholder using URL encoding so special characters in the password are safe.
For a local MongoDB instance you can instead use a complete URI with no password placeholder:
MONGODB_CONNECTION_STRING="mongodb://localhost:27017/thingtime"Schema-version migrations (/api/v1/admin/migrations*), the migrations panel on
/schemas, the admin panel, and raw database diagnostics are admin-gated. A
user is an admin when their user doc has meta.admin: true (promote/demote via
the admin panel or POST /api/v1/admin/set-admin) or their username is in the
bootstrap env allowlist:
ADMIN_USERNAMES="your-username,another-admin"Env-allowlisted usernames are a permanent override (they can't be demoted from the UI, so there's always a way back in) and are reserved at registration so nobody can squat an admin username before you register it.
Admins get the /admin dashboard (also under the drawer's Account section):
Users, Apps, Tiers, CI Control, and System management. CI Control presents the
feature/branch/PR/Actions/deployment topology and signed status history, with
allowlisted reconciliation and retry controls. The Tiers tab manages protected,
versioned subscription-tier Things in separate Live, Draft / not live, and
Archived sections. Admins can create a tier or draft a new revision, edit its
name, tagline, banner, currency, daily/weekly/monthly/yearly prices, six
computed-or-custom percentage-saved comparisons, Editor.js inclusions, and
quota defaults, then publish or archive without deleting history. User and app
assignments pin the exact immutable revision and quota snapshot, so later tier
changes never silently rewrite an existing customer's plan. The dashboard also
supports per-field quota overrides (null = unlimited), platform-level app
suspension, and many-to-many ownership links (assign accounts to an owner so
one login can switch into its service accounts without credentials, and assign
apps to co-managers).
App owners and linked co-managers use /apps/manage to see the app's measured
aggregate usage and choose among the current live tier cards (the bootstrapped
catalog starts with Free 5 GiB, Plus 25 GiB, Pro 100 GiB, and metered PAYG).
Cards show the configured banner, renewal prices, savings, and Editor.js
inclusions; selection sends both the stable tier id and exact live revision id.
Managers can also change the inherited per-app-user cap (50 MiB by default) and
assign or reset custom caps for one or many app users. The app Thing is the
aggregate ledger; protected relational app-storage Things hold per-user usage
and optional sub-tiers, so neither generic app editing nor an end user can
rewrite the accounting rows.
The live verification suites need a disposable local database. The app-storage suite is deliberately local-URL-only; the admin suite needs an env-admin's credentials (placeholders β use your own throwaway admin):
node remix/scripts/verify-app-storage.mjs http://127.0.0.1:10000TT_VERIFY_ADMIN_USER="your-admin-username" \
TT_VERIFY_ADMIN_PASS="your-admin-password" \
node remix/scripts/verify-admin-subscriptions.mjs http://127.0.0.1:10000JWT-backed browser sessions prefer ES256 asymmetric signing so other platforms can verify Thingtime-issued user tokens without knowing the private signing key. Configured asymmetric deployments publish the verification key at:
/api/v1/auth/jwksUse a P-256 private key in PKCS#8 PEM format and a public key in SPKI PEM
format. The env vars accept either full PEM text with escaped \n newlines or
base64-encoded PEM, which is easier to paste into Vercel:
JWT_PRIVATE_KEY="<base64-pkcs8-private-pem>"
JWT_PUBLIC_KEY="<base64-spki-public-pem>"
JWT_KEY_ID="thingtime-es256-1"
JWT_ISSUER="https://thingtime.com"Generate a fresh key pair with:
node <<'NODE'
const { generateKeyPairSync } = require('node:crypto');
const { privateKey, publicKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const encode = (key) => Buffer.from(key).toString('base64');
console.log('JWT_PRIVATE_KEY=' + encode(privateKey.export({ type: 'pkcs8', format: 'pem' })));
console.log('JWT_PUBLIC_KEY=' + encode(publicKey.export({ type: 'spki', format: 'pem' })));
console.log('JWT_KEY_ID=thingtime-es256-1');
console.log('JWT_ISSUER=https://thingtime.com');
NODEThe app also exposes a local helper UI at /crypto, backed by
/api/v1/crypto, for generating ES256 pairs, switching key encodings, checking
private/public key matches, verifying JWTs, and verifying signed messages
before pasting env vars into Vercel.
JWT_PUBLIC_KEY is recommended for clarity, but the server can derive it from
JWT_PRIVATE_KEY if only the private key is configured. Keep JWT_SECRET
temporarily as a legacy HS256 verifier while older browser cookies expire:
JWT_SECRET="<legacy-long-random-secret>"If neither asymmetric key material nor JWT_SECRET is set, preview and
production auth fail closed. Local development can still run without keys using
an insecure dev-only fallback.
The JWKS endpoint supports offline signature, issuer, and expiry verification. It does not tell external platforms whether the backing Mongo session has been revoked; add a server-side introspection endpoint before relying on live revocation checks outside Thingtime.
POST /api/v1/auth/password-reset ({ email }) always answers { ok: true } so
account existence can't be probed; when the email matches an account it sends a
single-use one-hour reset link to /reset-password?token=β¦. The confirm step
(POST /api/v1/auth/password-reset/confirm) burns the token atomically, sets
the new bcrypt hash, and revokes every live session. Requests are rate-limited
per IP (auth.passwordReset). Local dev + Vercel previews surface resetLink
in the JSON, mirroring the register route's dev verification link.
Email 2FA is opt-in per account (GET/POST /api/v1/auth/two-factor, requires a
verified email β toggle lives in Settings β Security). With it on,
POST /api/v1/login stops minting sessions from a password alone: it returns
{ requiresOtp: true, challenge, expiresAt } and emails a 6-digit code (only a
sha256 hash is stored, 10-minute TTL, atomically attempt-capped at 5); a second
POST /api/v1/login { challenge, code } completes login with a constant-time
comparison. Login attempts are rate-limited per IP (auth.login).
All outbound email flows through remix/app/api/utils/email/ β every send
writes an outbox row to email_messages first, checks the suppression /
unsubscribe lists, then delivers via AWS SES (or logs to the console in dev).
Auth wrappers in api/utils/auth/email.ts (sendVerificationEmail,
sendPasswordResetEmail, sendEmailOtp, sendNewsletterEmail) carry dotted
templateKeys (auth.verify_email, auth.password_reset, auth.email_otp,
newsletter.generic) and purpose metadata.
THINGTIME_EMAIL_PROVIDER="ses" # 'console' (default) or 'ses'
AWS_SES_REGION="us-east-1" # or AWS_REGION
AWS_SES_ACCESS_KEY_ID="<key id>" # or AWS_ACCESS_KEY_ID
AWS_SES_SECRET_ACCESS_KEY="<secret>" # or AWS_SECRET_ACCESS_KEY
THINGTIME_EMAIL_TRANSACTIONAL_FROM="Thingtime <no-reply@thingtime.com>"
THINGTIME_EMAIL_NEWSLETTER_FROM="Thingtime Updates <updates@thingtime.com>"
THINGTIME_EMAIL_REPLY_TO="support@thingtime.com"
AWS_SES_CONFIGURATION_SET="" # or THINGTIME_EMAIL_CONFIGURATION_SET
THINGTIME_EMAIL_FAIL_CLOSED="false" # fail-open unless "true"
SES_SANDBOX="1" # test throttle (1 msg/sec) for /tests
THINGTIME_EMAIL_TEST_RECIPIENT="support@thingtime.com"
THINGTIME_ADMIN_NOTIFICATION_EMAIL="admin@thingtime.com" # "new user" ops mailTHINGTIME_ADMIN_NOTIFICATION_EMAIL is where the internal new user
notification lands when a freshly registered account finishes email
verification (templateKey admin.new_user). It defaults to
admin@thingtime.com; point a fork or a preview/staging stack at its own inbox
so real admin mail is never generated by test signups. That mail is the cue to
grant the account public file and media uploads, which new signups do not
receive automatically β see
Public upload approval below.
New accounts start without permission to upload files or media, and verifying the email address does not grant it. The permission has two independent scopes, so an administrator can approve the public, private, or all variation per user:
| Scope | Covers | Flag |
|---|---|---|
public |
post, comment, and custom-emoji attachments | meta.publicUploads |
private |
message attachments + the user's own profile avatar/banner | meta.privateUploads |
all |
both of the above in one write | both flags |
The account carries meta.publicUploads: false and meta.privateUploads: false from registration; POST /api/v1/attachments/uploads answers
403 public_uploads_not_approved or 403 private_uploads_not_approved
(depending on the requested purpose) until an administrator approves that
scope from the /admin β Users tab's per-row Approve menu
(POST /api/v1/admin/users/public-uploads { userId, enabled, scope }; scope
defaults to public for pre-scope callers).
Each flag is deliberately tri-state, so nothing here needs a data migration:
| flag value | Meaning |
|---|---|
| absent | account predates the change β uploads stay enabled |
false |
withheld, awaiting admin approval (every new signup) |
true |
granted by an administrator |
Administrators bypass the flags entirely, so the account that grants the permission can never be locked out of the surface that grants it.
Use the SES API with an IAM key scoped to ses:SendEmail β do not create
SES SMTP credentials for the app path. GET /api/v1/email/config returns the
sanitized resolved config (never credentials); POST /api/v1/email/test-otp is
a dev/preview-only helper for the /tests page restricted to the configured
test recipient (or a plus alias of it).
Posts, comments and replies, Messenger messages and thread replies, custom reaction emoji, and profile avatar/banner images use direct, checksummed multipart uploads to a private S3 bucket. The browser receives short-lived part URLs, not AWS credentials; product records reference stable attachment ids, never expiring S3 URLs. Attachment bytes are reserved against the account's Thingtime storage tier before upload and remain charged until exact-version S3 deletion is confirmed. A stable client request id is hashed with the authenticated owner into an opaque owner-scoped attachment id, making lost start responses safely retryable without cross-account id squatting or existence disclosure.
Every surface binds only its own server-validated attachment purpose. Comment and reply files inherit the root post visibility through the complete parent chain. Message and thread files require current chat membership. Personal and community custom emoji bind one safe raster image to their exact owner/scope; community images require membership, while an emoji already used in a shared conversation remains renderable to its authenticated participants. Deleting an owning post/comment/message/emoji removes the exact S3 versions before Mongo rows and quota reservations are released. Custom Mongo data planes cannot bind or authorize these home-storage objects.
Profile media is limited to JPEG, PNG, GIF, WebP, or AVIF and 64 MiB per image. The server binds a ready upload only to its exact owner and requested avatar or banner slot in the same home-Mongo transaction as the profile update. Public profile rendering uses the stable same-origin content route; the bucket stays private. Replacing or removing managed profile media releases the old reference transactionally, but its bytes remain billed until the cleanup path permanently deletes the exact S3 version and removes the attachment Thing. External http(s) image URLs remain a separate, quota-saving alternative and are never fetched by the Thingtime server.
Configure only these server-side Vercel variables. Scope the production bucket
and role to Production only. Thingtime's develop Custom Environment and
standard feature Preview deployments use the separate development bucket,
role, data plane, and cleanup secret; never expose the production values to
either environment.
THINGTIME_PRIVATE_S3_ROLE_ARN="arn:aws:iam::<12-digit-account-id>:role/<production-attachment-role>"
THINGTIME_PRIVATE_S3_BUCKET="<private-bucket-name>"
THINGTIME_PRIVATE_S3_REGION="<aws-region>"
CRON_SECRET="<long-random-vercel-cron-secret>"The bucket name must be DNS-compatible without dots; dotted names are
rejected so every signed URL uses the unambiguous virtual-hosted S3 form. The
bucket must also belong to the same 12-digit AWS account named by the role ARN.
The runtime derives ExpectedBucketOwner from that ARN and fails closed when
the bucket owner differs.
In Vercel, mark all four values Sensitive. Give Production its values only
in the built-in Production environment. Give develop a distinct set only in
the branch-tracked Custom Environment named develop; never select the generic
Preview environment. CRON_SECRET authenticates only
/api/v1/attachments/cleanup; it is not a Thingtime user, PAT, app, or
service-account credential, and must never use a THINGTIME_* browser-visible
name. Use different secrets for Production and develop.
The develop Custom Environment must use an exact develop branch matcher and
own https://dev.thingtime.com. Its Vercel OIDC subject is
owner:<vercel-team-slug>:project:<vercel-project-name>:environment:develop.
This is intentionally different from ordinary PR deployments, whose subject
ends in environment:preview. Branch-scoped Preview variables alone are not an
AWS boundary because Vercel's Preview OIDC subject contains no Git branch; do
not trust environment:preview for the develop role.
The role must use Vercel OIDC temporary credentials and an exact production
subject for this project. Do not create an S3 IAM user, reuse the SES IAM user,
or set generic AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_REGION
variables for attachments. Restrict its object policy to the app's objects/*
prefix. With Vercel's recommended team issuer mode, use this placeholder-only
trust policy (replace every angle-bracket value):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<12-digit-account-id>:oidc-provider/oidc.vercel.com/<vercel-team-slug>"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.vercel.com/<vercel-team-slug>:aud": "https://vercel.com/<vercel-team-slug>",
"oidc.vercel.com/<vercel-team-slug>:sub": "owner:<vercel-team-slug>:project:<vercel-project-name>:environment:<production-or-develop>"
}
}
}
]
}Create one role per environment. Substitute production for the Production
role and develop for the develop role; never wildcard the environment portion
of sub and never let one role trust both subjects.
Attach this placeholder-only permissions policy to the role. Keep generic
s3:DeleteObject out: in a versioned bucket it can create a delete marker
without permanently removing the billed object version.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ThingtimePrivateAttachments",
"Effect": "Allow",
"Action": [
"s3:AbortMultipartUpload",
"s3:DeleteObjectVersion",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:ListMultipartUploadParts",
"s3:PutObject",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging"
],
"Resource": "arn:aws:s3:::<private-bucket-name>/objects/*"
}
]
}The runtime role needs only those object actions:
s3:PutObjectands3:PutObjectTagging(the MPU starts with a pending tag)s3:GetObjectands3:GetObjectVersions3:DeleteObjectVersions3:AbortMultipartUploadands3:ListMultipartUploadPartss3:PutObjectVersionTagging
Do not grant s3:ListBucket, s3:ListBucketMultipartUploads, ACL,
public-read, or bucket-administration actions. Completed attachments persist
the opaque S3 VersionId; sniffing, tagging, download, and deletion all target
that exact verified version. Exact-version deletion happens before the Thingtime
storage reservation is refunded, so bucket versioning cannot hide unmetered
noncurrent bytes.
Keep both account- and bucket-level S3 Block Public Access enabled, Bucket Owner
Enforced object ownership on, and bucket versioning enabled. Bucket policy
should explicitly deny non-TLS requests and TLS below 1.2. The
aws:PrincipalIsAWSService condition avoids accidentally blocking AWS service
principals whose network context AWS redacts:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::<private-bucket-name>", "arn:aws:s3:::<private-bucket-name>/*"],
"Condition": {
"Bool": {
"aws:SecureTransport": "false",
"aws:PrincipalIsAWSService": "false"
}
}
},
{
"Sid": "DenyTLSBelow12",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::<private-bucket-name>", "arn:aws:s3:::<private-bucket-name>/*"],
"Condition": {
"NumericLessThan": {
"s3:TlsVersion": "1.2"
},
"Bool": {
"aws:PrincipalIsAWSService": "false"
}
}
}
]
}Configure CORS with one exact origin per bucket, PUT, and the one
application-authored request header: the production Thingtime origin for the
production bucket, and https://dev.thingtime.com for the develop bucket. The
uploader deliberately sends a Blob with no Content-Type, and completion
obtains ETags/checksums server-side with ListParts, so no S3 response headers
need to be exposed:
[
{
"AllowedHeaders": ["x-amz-checksum-sha256"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://<environment-origin>"],
"ExposeHeaders": [],
"MaxAgeSeconds": 300
}
]Lifecycle must abort incomplete multipart uploads after seven days and remove noncurrent versions after 30 days. This AWS CLI/API-shaped placeholder applies both actions only to Thingtime's object prefix (the S3 console asks for the same rule fields):
{
"Rules": [
{
"ID": "thingtime-private-attachment-cleanup",
"Status": "Enabled",
"Filter": { "Prefix": "objects/" },
"NoncurrentVersionExpiration": { "NoncurrentDays": 30 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}Presigned URLs work with a private bucket; public access must stay off.
Production uses the app's hourly Vercel Cron at minute 17. Vercel Cron runs
Production deployments only, so the develop Custom Environment instead needs
an external hourly scheduler that sends the same exact bearer header to
https://dev.thingtime.com/api/v1/attachments/cleanup. Thingtime uses a
dedicated AWS EventBridge API Destination for that call; keep its connection
secret distinct, its invocation role limited to that one destination, and its
rate at one request/second. Configure the Connection as API-key auth with
header name Authorization and value Bearer <develop-cron-secret>. Restrict
the role trust to events.amazonaws.com plus the exact rule aws:SourceArn
and account, and grant only events:InvokeApiDestination on the exact API
Destination ARN. Schedule cron(17 * * * ? *); never put the connection secret
in the rule payload, repository, or logs. Both paths process at most 1,000 rows with a
25-second wall-clock budget per pass. Pending cancellations
that issued a presigned part URL stay conservatively billed through an eight-day,
lifecycle-backed settlement window. Cleanup then requires two empty
Abort/ListParts checks at least one hour apart before HEAD verification,
exact-version deletion, and transactional refund. This prevents a signed part
PUT that finishes late from escaping tier accounting; the seven-day S3
incomplete-MPU lifecycle remains a required independent guard.
An MPU that never issued a part URL has no possible late browser PUT and can be
refunded promptly after Abort/ListParts/HEAD proves it empty.
Activity notifications (friend requests, new followers, comments, replies,
reactions, shares β plus an optional weekly summary digest) can also email the
recipient. They ride the same emit calls as the in-app bell, are always
fire-and-forget, only go to verified addresses, and honor the per-user channel
matrix from Settings β Notifications (/api/v1/notifications/settings: per
type Γ channel switches plus a master switch per channel; the two high-volume
post types are email-opt-in). Sends are capped per recipient per hour, and
every email footer carries a manage link plus a one-click unsubscribe link
(GET /api/v1/notifications/email/unsubscribe?uid=β¦&token=β¦, an HMAC token β
no session needed).
THINGTIME_EMAIL_NOTIFICATIONS_FROM="Thingtime <no-reply@thingtime.com>"
# optional; falls back to the
# transactional from-address
THINGTIME_EMAIL_UNSUB_SECRET="" # optional HMAC secret for unsubscribe
# links; falls back to JWT_SECRET /
# JWT_PRIVATE_KEY
CRON_SECRET="<random string>" # lets the Vercel cron trigger the
# weekly digest run
APP_URL="https://your-deployment.com" # absolute links in emailsThe weekly digest is scheduled in remix/vercel.json (crons) against
GET /api/v1/notifications/email/weekly-summary; Vercel attaches
Authorization: Bearer <CRON_SECRET> automatically when that env var exists.
Signed-in admins can run the same endpoint manually (?dryRun=1 or
POST { dryRun: true } previews without sending), and the run is idempotent β
a six-day per-recipient lookback in the email_messages outbox prevents
double-sends.
Apps and backend services can create service-owned Thingtime accounts through:
POST /api/v1/auth/service-accountThe endpoint is self-service: it does not require a server provisioning secret, but it does require a unique, valid email address. The account must verify that email within seven days. Until verification, the bearer token works only during that grace window; after the deadline, authenticated requests for the service account are rejected until the email is verified.
curl -X POST "https://thingtime.com/api/v1/auth/service-account" \
-H "Content-Type: application/json" \
-d '{
"serviceName": "CodexTime",
"username": "codextime",
"email": "codextime-service@example.com",
"displayName": "CodexTime"
}'The response includes an accessToken that the service can use as a normal
Thingtime bearer token:
Authorization: Bearer <accessToken>Service account tokens are intentionally non-expiring JWTs with revocable Mongo
session records. The session expiresAt value is null, the JWT has no exp
claim, and the account starts with a storageAllowanceBytes value of
5368709120 (5 GiB). The email-verification deadline is returned as
verificationRequiredBy. Revoke the token by revoking or deleting its backing
session document.
See docs/api/service-accounts.md for the complete request and response shape.
Lopu musings can optionally use Claude and/or OpenAI. Without these keys, the endpoint serves the built-in fallback library.
ANTHROPIC_API_KEY="<anthropic-api-key>"
OPENAI_API_KEY="<openai-api-key>"
LOPU_PROVIDER="claude"Every Claude-backed musing reads the current Admin β AI workflow model order
from Thingtime.PRConflictAutoResolverModelWaterfall; a named preference wins
over LOPU_CLAUDE_MODEL. When the Admin primary is default,
LOPU_CLAUDE_MODEL remains the Anthropic-valid provider default. OpenAI is a
separate provider and continues to use LOPU_OPENAI_MODEL (or its documented
built-in default), including when it is selected first with LOPU_PROVIDER.
When an AI key is configured, the musing endpoint uses MongoDB to allow 10 AI-backed musings per detected IP address per rolling hour. Requests over the limit, or requests made while the rate-limit collection is unavailable, stream the preset fallback responses instead of calling an AI provider.
develop is the integration branch; main is the release branch. Four
workflows keep them flowing without manual branch surgery, giving two
complementary ways to ship:
- Promote features to main (
.github/workflows/promote-features-to-main.yml) scans PRs merged intodevelopand opens one promotion PR per feature againstmain(cherry-pickedpromote/pr-<n>-<slug>branches), so every change can get a second, release-focused review on its own. PRs that share a feature group (aPromotion-Group: <key>body line, astack:<key>/group:<key>/feature:<key>label, afeature/<key>/...branch, or afeat(<key>): ...title) are opened as a stacked chain in merge order β review and merge bottom-up, deleting each branch on merge. Label a develop PRno-promoteto keep it out of the train; close a promotion PR to reject that change formainpermanently. - Promote develop to main (
.github/workflows/promote-develop-to-main.yml) keeps one standing all-or-nothing PR open (headdevelop, basemain). When everything ondevelopis deemed mergeable, merge it instead of merging every feature individually. The two trains never fight: after an omnibus merge the per-feature workflow sees the content already onmain, skips it, and automatically closes any open promotion PRs whose diff has become empty. - Sync main into develop back-merges
mainafter promotions land. - The AI conflict/rebase workflows keep promotion PRs and stacks mergeable.
Fork setup: everything runs with the default GITHUB_TOKEN, but promotion
PRs it creates will not trigger CI, and promotion branches touching
.github/workflows/** cannot be pushed. Optionally add a PROMOTION_PAT
repository secret (fine-grained token with Contents + Pull requests +
Workflows read/write, placeholder value github_pat_...) to lift both limits;
SYNC_BRANCHES_PAT / CONFLICT_RESOLVER_PAT are honoured as fallbacks.
The footer can show live Vercel deployment/build status. It works in a limited tokenless mode on Vercel, but full status, dashboard links, build state, last ready time, and active polling need a Vercel REST API token.
Local development, preview deployments, and production deployments expose
/vercel, backed by /api/v1/vercel/deployments, to scan recent Vercel pages
for the latest deployment per unique branch with timestamps, preview links,
deployment-detail links, current Vercel states, total branches counted, and an
optional display cap using the same server-only token configuration.
Add this as a sensitive Vercel project environment variable:
VERCEL_API_TOKEN="<vercel-rest-api-token>"Create this token from Vercel account/team token settings, not from the OAuth
App / "Sign in with Vercel" setup page. The token needs access to the Vercel
team and project that own the deployment. A persistent 403 from
/api/v1/vercel/status or /api/v1/vercel/deployments usually means the token
was created for the wrong account/team, has expired, or lacks project access.
These Vercel variables are optional because the hosted Vercel runtime normally provides enough deployment metadata automatically, and token-backed deployment pages read the project name/slug from the Vercel API:
VERCEL_PROJECT_ID="<project-id>"
VERCEL_TEAM_ID="<team-id>"
VERCEL_DASHBOARD_TEAM_SLUG="<team-or-scope-slug>"Use VERCEL_DASHBOARD_TEAM_SLUG when tokenless dashboard links need to point to
a Vercel team slug that differs from the GitHub repository owner.
Vercel automatically provides variables such as VERCEL, VERCEL_ENV,
VERCEL_URL, VERCEL_BRANCH_URL, VERCEL_GIT_COMMIT_REF, and
VERCEL_GIT_COMMIT_SHA during deployments.
A pull request's base branch does not select its Vercel environment. A feature
branch targeting develop is therefore still an ordinary Preview unless the
trusted controller in .github/workflows/develop-pr-preview.yml explicitly
deploys its exact head SHA to the develop Custom Environment. Thingtime now
also assigns the current develop runtime variables to generic Preview, so an
ordinary newly built Preview shares the development data/services even without
the controller. The controller remains responsible for the stable
pr-<number>.previews.dev.thingtime.com alias, identity/SHA gates, status
comment, and marker-scoped cleanup.
The workflow deliberately uses two stages. The product branches retain only a
thin event listener pinned to the reusable implementation on the protected
github-actions branch. Its pull_request_target job has no environment or
Vercel secret, checks out no code, and emits only a bounded
repository_dispatch payload. The privileged dispatch job runs in the trusted
default-branch event context behind the vercel-develop-pr-control environment
while checking out the controller from github-actions. It proves the
source workflow path/run, repository, same-repository PR, head SHA, action, and
triggering actor through GitHub's API, then re-reads the live PR. Both the PR
author and triggering actor must be explicitly allowlisted, currently hold
write/admin permission, and the non-draft PR must still target develop.
Neither GitHub job checks out or executes PR-head code; Vercel performs the
remote build only after those gates pass.
The reusable implementation and controller script must first merge to the
protected github-actions branch. The thin listener must then reach the
repository's default main branch through the normal develop promotion path.
pull_request_target loads the listener from the default branch, so merely
adding it to a feature branch does not activate the controller. Thingtime's
active main Basic Protection ruleset
has no bypass: it requires a pull request, resolved review threads, strict Web
CI and CodeQL status checks, and blocks branch deletion and force-pushes. The
tracked CODEOWNERS file requests owner review, but independent CODEOWNER
approval is optional future hardening once a second trusted collaborator can
review controller changes. The controller Environment intentionally has no
required reviewer because that would pause event cleanup and every six-hour
scheduled reconciliation instead of letting them run automatically.
Thingtime's protected GitHub Environment vercel-develop-pr-control allows only
the main deployment branch. It contains the nine controller variables and a
dedicated 90-day Vercel token scoped to the owning team. Vercel does not offer
a project-scoped PAT for this API surface, so the protected Environment and the
controller's exact project/team checks are the project boundary. The masked
unsigned S3 CORS probe secret is also installed. The secret-free
pull_request_target stage hands off to a repository_dispatch run in the
default-branch context; scheduled runs also use the default branch, and the
workflow refuses a manual dispatch from any other ref. Forks must use values
from their own Vercel project; the examples are placeholders and must not be
committed with live credentials or identifiers:
# GitHub Environment secrets
VERCEL_DEVELOP_DEPLOY_TOKEN="<dedicated-Vercel-deployment-token>"
THINGTIME_DEVELOP_S3_CORS_PROBE_URL="https://<exact-develop-bucket>.s3.<region>.amazonaws.com/<probe-object>"
# GitHub Environment variables
VERCEL_PROJECT_ID="<Vercel-project-id>"
VERCEL_PROJECT_NAME="<Vercel-project-name>"
VERCEL_TEAM_ID="<Vercel-team-id>"
VERCEL_TEAM_SLUG="<Vercel-team-slug>"
VERCEL_GITHUB_REPO_ID="<Vercel-linked-GitHub-repository-id>"
VERCEL_CUSTOM_ENVIRONMENT_ID="<Vercel-develop-custom-environment-id>"
DEVELOP_PREVIEW_TRUSTED_ACTORS="<trusted-GitHub-login>[,<trusted-GitHub-login>]"
PREVIEW_ALIAS_SUFFIX="<preview-alias-suffix>"
STABLE_DEVELOP_DOMAIN="<stable-develop-domain>"VERCEL_CUSTOM_ENVIRONMENT_ID must contain the exact immutable ID returned for
the develop Custom Environment, not the display slug develop. The author and
triggering actor must both appear in DEVELOP_PREVIEW_TRUSTED_ACTORS and
still hold current write/admin repository permission. Keep the Vercel
environment's branch matcher on the literal develop branch. Bind
dev.thingtime.com to that Git branch (gitBranch: develop and no
customEnvironmentId on the domain), not to the entire Custom Environment,
and keep the Custom Environment's own domain list empty. The controller assigns
only the verified PR wildcard alias explicitly. This leaves the stable
development hostname on the real develop branch while PRs receive only
https://pr-<number>.previews.dev.thingtime.com.
Generic Preview intentionally receives every runtime variable currently
assigned to the develop Custom Environment, while retaining its existing
Preview-only filesystem, CI, repository, webhook, and workflow variables. This
includes the development-only APP URL, CRON, JWT, MongoDB, and S3 settings, plus
the AI, SES/email, and Vercel API values that develop intentionally shares
with Production. Production MongoDB, JWT, and S3 settings remain separate and
are not assigned to Preview.
For Thingtime, set PREVIEW_ALIAS_SUFFIX=previews.dev.thingtime.com and
STABLE_DEVELOP_DOMAIN=dev.thingtime.com. Forks should replace both with
domains they control. The masked Environment secret
THINGTIME_DEVELOP_S3_CORS_PROBE_URL is required and must be a credential-free
HTTPS object URL on the exact develop bucket, with no query string or presigned
parameters. The controller sends only an unauthenticated CORS OPTIONS probe
and fail-closes alias publication if it is not accepted.
*.previews.dev.thingtime.com is registered, verified, and detached from
both Git branches and Custom Environments in Vercel. Its remaining Thingtime
DNS setup keeps Cloudflare authoritative for the apex. The DNS only
(grey-cloud) CNAME from *.previews.dev to cname.vercel-dns.com routes wildcard
traffic, while wildcard TLS issuance and renewal require two narrow NS
delegations from _acme-challenge.previews.dev to ns1.vercel-dns.com and
ns2.vercel-dns.com. Do not move the thingtime.com apex to Vercel nameservers
or delegate a broader subtree. Dedicate _acme-challenge.previews.dev to this
preview wildcard, because that delegation gives Vercel control of certificate
validation for the subtree and can prevent another provider from issuing there.
Vercel may still label this externally managed arrangement DNS Change Recommended or return misconfigured: true; that advisory asks to move the
apex nameservers and is not the publication gate. The controller instead
requires the live probe hostname to resolve to Vercel's currently recommended
CNAME target, then verifies HTTPS on the exact alias after assigning it.
Making Vercel authoritative for the domain would normally remove the advisory,
but Thingtime intentionally keeps Cloudflare authoritative and delegates only
the two narrow ACME validation subtrees.
See Vercel's official
wildcard-without-Vercel-nameservers guide.
Forks should first add their own wildcard to Vercel and copy every CNAME or
verification record Vercel currently displays for that domain; do not copy
another project's account-specific targets.
The develop S3 bucket permits browser upload CORS from the stable development origin, the controller-managed PR aliases, and Thingtime's generated Vercel Preview hostnames. Downloads remain same-origin through Thingtime and the bucket stays private:
[
{
"AllowedHeaders": ["x-amz-checksum-sha256"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["https://dev.thingtime.com", "https://*.previews.dev.thingtime.com", "https://thingtime-*-lopugits-projects.vercel.app"],
"ExposeHeaders": [],
"MaxAgeSeconds": 300
}
]Activation status as of 2026-08-11: the no-bypass main ruleset, protected
Environment, nine controller variables, dedicated 90-day Vercel token, masked
THINGTIME_DEVELOP_S3_CORS_PROBE_URL secret, shared develop/Preview runtime
scope, generic-Preview OIDC trust, develop bucket CORS, detached Vercel
wildcard, DNS-only wildcard CNAME, narrow ACME NS delegation, and wildcard TLS
are complete for *.previews.dev.thingtime.com. The first live dispatch
authenticated successfully and exposed the external-DNS advisory mismatch.
The corrected implementation must merge to github-actions, and this thin
listener must reach main through develop; a successful post-fix exact-SHA
deployment, alias publication, CORS probe, and attachment upload/removal check
then complete the activation checklist.
CORS is not authorization. The bucket remains private, while the development
AWS role explicitly trusts both Thingtime's environment:develop and
environment:preview OIDC subjects. Every new ordinary Preview can therefore
read or mutate the same development MongoDB/S3/data plane and use the same
private integration values as dev.thingtime.com. Treat all branches Vercel is
allowed to build as trusted development code, use disposable data, and keep
production MongoDB/JWT/S3 credentials out of Preview.
*.previews.thingtime.com is reserved for a separate future production-preview
controller. Do not point the develop controller at that suffix, copy the
production S3 role into generic Preview, or let ordinary Vercel feature/fork
previews assume the production AWS role. A production-preview controller must have its
own trusted actors, protected control environment, exact production OIDC trust,
deployment cleanup, CORS probe, and bucket CORS rule before that namespace is
activated.
Every generic Preview and eligible controller deployment intentionally shares
the same development MongoDB, S3 bucket, quotas, and other runtime state as
dev.thingtime.com. It is a trusted integration surface, not an isolated
sandbox: use disposable test accounts/data and do not allow Vercel to build
untrusted code in this project. The controller updates one marker comment with
deploying/ready/failure state, moves
the PR alias only after the exact SHA is ready and revalidated, and deletes only
its marker-tagged superseded resources. Close/retarget/draft handling removes
the alias, inactivates the transient GitHub Deployment, and deletes its tagged
Vercel deployments. A six-hour scheduled reconciliation repeats marker-scoped
cleanup after an interrupted or missed event without touching the stable
develop deployment; manual dispatch safely revalidates one supplied PR.
See VERCEL_DEPLOYMENTS.md and the Develop-target checklist in TESTING.md for
the operator runbook.
The footer environment selector can compare public origins for this tab, local,
development, staging, and production. These values are browser-visible
THINGTIME_ values, so use public origins only and never include tokens,
passwords, or other secrets:
THINGTIME_PRODUCTION_STATUS_ORIGIN="https://thingtime.com"
THINGTIME_DEV_STATUS_ORIGIN="https://dev.thingtime.com"
THINGTIME_STAGING_STATUS_ORIGIN="https://staging.thingtime.com"
THINGTIME_LOCAL_STATUS_ORIGIN="http://localhost:9999"Unset values fall back to https://thingtime.com, https://dev.thingtime.com,
https://staging.thingtime.com, and http://localhost:9999.
Browser-visible loader data uses an explicit allowlist. It includes only the
public local/development/staging/production status origins plus derived branch,
Vercel deployment, and status-display labels. Every other environment variable
remains server-only β including all THINGTIME_* webhook, router, email,
credential, token, password, and private-key values. Never add a new public
value by prefix convention; add and review its exact key in
remix/app/root-data.server.ts.
Naming still matters for reviewability even though it no longer decides
exposure: use the THINGTIME_PRIVATE_ namespace for server-only Thingtime
integrations such as S3, and keep secrets such as MongoDB passwords and Vercel
API tokens unprefixed and server-only.
The native iOS app lives in iOS/ and defaults its embedded WKWebView to
https://thingtime.com. TestFlight builds can target a Vercel branch or preview
deployment by setting a non-secret build-time URL:
export THINGTIME_WEB_URL="https://<vercel-branch-preview-host>"For repeatable local uploads, copy iOS/.env.example to iOS/.env, fill in the
TestFlight values, and run:
iOS/scripts/testflight-beta.shiOS/.env is ignored by git. The value is baked into that uploaded app build;
future web changes on the same Vercel branch URL do not require a new iOS
binary.