Skip to content

Clarify cross-deployment account-hint environments - #373

Open
lopugit wants to merge 12 commits into
codex/thingtime-mcp-desktop-connectorsfrom
codex/account-hint-environment-labels
Open

Clarify cross-deployment account-hint environments#373
lopugit wants to merge 12 commits into
codex/thingtime-mcp-desktop-connectorsfrom
codex/account-hint-environment-labels

Conversation

@lopugit

@lopugit lopugit commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • label cross-deployment account hints by their true source deployment; expandable rows reveal environment labels, origin, and last-active time
  • make account-hint APIs user-private and non-cacheable, and recover foreign *.vercel.app logins through the configured first-party development authority
  • add an explicit public data-authority contract: THINGTIME_DATA_ENV=production|development|custom:<id>, with a safe authority/federation identity in root data and /api/v1/capabilities (api.capabilities 1.1.0)
  • require Electron endpoint compatibility to negotiate that identity rather than infer a database/auth environment from a branch or Vercel deployment tier
  • scope signed peer discovery, relational peer leases, NDJSON stream signatures, gossip, and indexes by federation ID so aliases sharing development can discover each other but production/custom groups cannot cross-join

Validation

  • test:account-hints — 9/9
  • test:root-data — 4/4
  • test:peers — 6/6
  • test:api-capabilities — 4/4
  • Electron endpoint-compatibility suite — 62/62
  • focused ESLint — clean
  • build:client, build:server, and root build:vercel — pass
  • deployed preview manifest verified at api.capabilities=1.1.0, dataEnvironment=development, federationId=development, with authorityOrigin=https://dev.thingtime.com; account-hints headers verified as Cache-Control: private, no-store and Vary: Cookie

Known baseline

  • test:collections currently fails an existing contradictory assertion: the current source intentionally creates things_passkey_link_key_unique while the historical retired-index list still asserts it must be absent. This change only replaces the deployment-peer sort index with a federation-prefixed equivalent and does not touch that Things index.

@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thingtime Ready Ready Preview Aug 26, 2026 11:16pm
thingtime (develop) Ready Ready Preview Aug 26, 2026 11:16pm

Request Review

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged codex/thingtime-mcp-desktop-connectors into codex/account-hint-environment-labels — conflicts auto-resolved by the resolve-pr-conflicts workflow.

Conflicted files:

  • remix/CHANGELOG.md

Please review the merge commit before relying on it.

…environment-labels (AI-resolved conflicts)

Conflicted paths: remix/CHANGELOG.md

Resolved by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32713758114

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Lopu repository review

Lopu reviewed this PR against codex/thingtime-mcp-desktop-connectors as Thingtime's principal PR and repository manager. Using Claude Opus 5.

Lopu made justified improvements and pushed ef15578 to codex/account-hint-environment-labels.

Lopu review — PR #373 · Clarify cross-deployment account-hint environments

Compared: codex/account-hint-environment-labels @ 9ca92cb against base
codex/thingtime-mcp-desktop-connectors @ f42abb9. The branch is current
with its base. 29 files excluding graphify-out/ (+831 / −182).

The failing check — diagnosed, and it is not this PR

control-plane / Build + typecheck ratchet + unit tests fails on
run 33012759599:

✖ current Things index plan keeps four slots free below MongoDB hard limit
  AssertionError: Things index plan uses 62/64; reserve 4 slots for safe upgrades
  at app/api/utils/mongodb/indexBudget.test.ts:48

I reproduced it locally in the PR worktree, then re-ran the identical test with
this PR's collections.ts swapped for the base version at f42abb9:

Tree Result
PR head 9ca92cb Things index plan uses 62/64 — fail
Base f42abb9 (base file, same test) Things index plan uses 62/64 — fail

Byte-identical failure, identical count. This PR adds zero things
indexes — its only collections.ts change is on deploymentPeers (plus
un-retiring one name in RETIRED_THINGS_INDEXES, which can only relax the
second assertion, never the count). The failure is inherited from the base
branch, and PR #68 (codex/thingtime-mcp-desktop-connectors, the base)
shows the same job red plus a second failing job.

I then bisected it to the exact commit, by running the same guard test against
this file at three points in this branch's history:

Commit Index plan
b615534 — this branch's head at 09:16 today passes, under the ceiling
f42abb9 — the base, after 126dc2d merged develop into it at 12:31 62/64 — fail
9ca92cb — current head, after that base landed here at 20:47 62/64 — fail

So the regression entered through the base branch's own develop merge
(126dc2d), and reached this PR when that base was merged in at 20:47 today.
That also reconciles the earlier Lopu note on this PR, which reported this job
green — that was accurate for b615534; the ceiling was crossed nine hours
later by a merge, not by an edit.

For the record, I dumped the actual plan by driving createThingsDataIndexes()
with a stub collection: 60 distinct indexes, + _id_ + the home-only
migration_diagnostic_expires_at TTL = 62. develop produces 47 (+2 = 49).
The extra 13 are #68's own feature indexes — things_device_* ×8,
things_external_* ×4, things_ai_connection_key_unique, plus
things_chat_community, things_dm_key_lookup, things_thread_root.

I deliberately did not "fix" this here. Choosing which of #68's indexes to
consolidate is #68's design decision, and PR #405's new
TODO/claude-todo/20-index-budget-consolidation.md is the plan for it — that
doc even names this test and this lineage explicitly (§4 step 5). Landing an
index-budget change on a stacked child branch would put the fix in the wrong
place and guarantee a conflict the moment #68 moves. This check clears when
#68 clears; I have commented on #68 with the numbers.

Findings on the PR's own content

The substance is a careful, security-positive change: a public
THINGTIME_DATA_ENV descriptor, and a federationId bound into every peer
signature so a deployment on one identity database cannot be replayed against
another.

  • peerSigningPayload, publicPeerSigningPayload and
    peerStreamSigningPayload all take federationId into the signed bytes, and
    PEER_DISCOVERY_PROTOCOL_VERSION goes 1 → 2. Correct: changing what is
    signed without changing the version would have let a v1 signature verify
    against a v2 payload shape.
  • verifyPeerRequest validates the wire x-thingtime-peer-federation-id
    against the local one before any signature work, and the HMAC comparison
    stays timingSafeEqual with an explicit length guard.
  • listActivePeers filters on federationId, so a legacy row without the
    field is invisible rather than cross-linked, and announcePeer re-reads and
    refuses to return a row whose federationId did not stick.
  • dataEnvironment.ts fails closed on every axis: custom: requires a
    matching ^[a-z0-9](...)$ id and an authority origin, and
    normaliseOrigin rejects non-https, credentials-in-URL, and any URL carrying
    a path, query or fragment.
  • privateAccountHintsHeaders preserves an existing Vary: Origin from the
    credentialed CORS response while adding Vary: Cookie and
    Cache-Control: private, no-store — the right shape for a per-session hint.
  • accountHintOrigin.ts is honest about what it can prove: it labels the
    deploy environment visible in the URL and does not claim a data-plane
    environment it cannot verify. Good call, and the comment says so.

Non-blocking observations

  1. Protocol 1 → 2 is a hard cutover. During a rolling deploy, v1 peers and
    v2 peers will 401 each other until every deployment is updated. Leases are
    10 minutes so the mesh self-heals, but it is worth a line in
    remix/CHANGELOG.md / the runbook so an operator seeing mutual 401s knows
    it is expected and bounded.
  2. Legacy rows are claimable during the upgrade window. announcePeer's
    filter admits { federationId: { $exists: false } }, so the first v2
    deployment to announce a given origin adopts that row into its federation.
    Bounded by the pinned origin and the signingPublicKey match, so I do not
    think it is exploitable — but it is the one place where the federation fence
    is intentionally open, and it deserves a comment saying it is a one-time
    migration allowance.

Change made

remix/app/api/utils/mongodb/collections.ts — the new deploymentPeers
index is created with a bare createIndex:

col('deploymentPeers').createIndex({ federationId: 1, lastSeenAt: -1, origin: 1 }),

That changes the key shape of the index it replaces
({ lastSeenAt: -1, origin: 1 }). MongoDB will not drop the old one — a plain
createIndex with a new shape simply builds a second index — so every already-
deployed database keeps the superseded index forever. This repo already has the
convention for exactly this case (createIndexReplacing(collection, keys, { name }, legacyNames), used a dozen times in the same file, with
dropIndexRetrying treating an absent index as fine). I switched to it:

createIndexReplacing(
  col('deploymentPeers'),
  { federationId: 1, lastSeenAt: -1, origin: 1 },
  { name: 'deployment_peers_federation_recent' },
  ['lastSeenAt_-1_origin_1']
),

Naming the index also makes it referenceable from a future retirement list
instead of depending on MongoDB's auto-generated name a second time. The
surrounding indentation was also normalized back to the file's spaces (the new
line had arrived with a stray tab).

Validation run

  • npx tsx --test app/api/utils/mongodb/indexBudget.test.ts — before and after
    my edit: 1 pass / 1 fail, the failure being the inherited budget assertion
    above, unchanged. The module still parses and loads, so the
    createIndexReplacing swap does not break the ensure path.
  • npx tsx --test over the PR's new suites:
    accountHintsHeaders.test.ts, dataEnvironment.test.ts,
    peerDiscovery.test.ts, accountHintOrigin.test.ts, ssoHub.test.ts
    15 / 15 pass, including "peer request signatures bind the raw body,
    method and route with a bounded timestamp" and "Vercel previews use the
    development Thingtime authority".
  • apiCapabilities.test.ts and root-data.server.test.ts could not run in
    this sandbox (Cannot find module 'jose' — the review worktrees carry no
    node_modules). CI runs both and they pass there; the only CI failure on
    this head is the index-budget assertion.

CodeQL

16 open alerts on this head — every one of them pre-existing on develop and
in files this PR does not touch. I inspected each and filed evidence-backed
dispositions for 11 (4 Math.random() uses in disposable-environment
verification scripts → used in tests; 7 render-time DOM/debug identifiers and
one dead unreferenced stub → false positive).

5 left open on purpose — I will not dismiss a real finding to green a
board, and none of them belongs in this PR:

# Rule Why it stays open
18, 19 js/prototype-pollution-utility app/smarts/index.tsx really is a recursive merge that writes value1[value2Property] with an unfiltered key. Genuine class; needs its own PR.
49 js/stack-trace-exposure Taint reaching json() in app/api/http.ts; the source is a caller, so it needs the flow path to judge — not demonstrably inapplicable.
45 js/cors-permissive-configuration cors({ origin: '*' }) in deprecated/api. Almost certainly undeployed, but "undeployed" is a claim I could not prove from the repo alone.
17 js/double-escaping Real ordering bug in the Raycast helper: \\ is unescaped after \{/\[, so \\{ collapses to {. Low impact, still a real bug.

View Lopu workflow run

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged codex/thingtime-mcp-desktop-connectors into codex/account-hint-environment-labels — conflicts auto-resolved by the resolve-pr-conflicts workflow.

Conflicted files:

  • remix/CHANGELOG.md
  • remix/app/api/utils/peers/peerDiscovery.ts
  • remix/package.json

Please review the merge commit before relying on it.

…environment-labels (AI-resolved conflicts)

Conflicted paths: remix/CHANGELOG.md, remix/app/api/utils/peers/peerDiscovery.ts, remix/package.json

Resolved by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32735798678

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions
github-actions Bot temporarily deployed to develop-pr-373 August 24, 2026 14:21 Destroyed
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

✅ Develop S3 preview ready

The alias passed the develop bucket CORS preflight and a final live PR/SHA fence.

Generic Vercel Preview deployments use the shared development runtime; this controller adds the stable exact-SHA alias and marker-scoped cleanup.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Lopu — the failing check is diagnosed and repaired; and your "Known baseline" note is now stale

[auth] Service account creation — 507/508, and it was not this PR

I traced it rather than retrying it.

  1. Not introduced here. The identical test fails with the identical 12001/12002 ms abort on this PR's base branch (codex/thingtime-mcp-desktop-connectors, run 32702761653) and on every earlier head of this branch. develop's web-ci is green over the same window, so the regression entered somewhere in the MCP-desktop-connectors stack, above develop.
  2. The server actually succeeded. The dev-server log tail from the failing job prints the verification email for tt-api-test-…@example.invalid — the address shape only uniqueServiceAccountBody produces. That send is the last statement in provisionServiceAccount (fire-and-forget, after createUserAccount, createSession and signJwt). The request ran to completion; the client aborted first.
  3. What takes the time. provisionServiceAccountcreateUserAccountregisterUser.ts:86 await ensureIndexes(). On the base branch that gained pruneRetiredHomeThingsIndexes + migrateDeviceIndexLayout (two pipeline updateManys, TTL/unique index drops, two replacement builds) ahead of the ~50 things index creations. Against a cold CI Mongo that bootstrap is measured in seconds.
  4. Why only this test. auth-service-account-create is the first account-creating test in the table, so it alone pays the cold bootstrap. The only two other account-creating tests — email-signup-verification and email-service-account-verification — sit later in the file and already carry timeoutMs: 30000. They pass in 1782 ms and 75 ms, because by then the indexes exist.

So this one test was simply left on the 12 s default while its two siblings were given 30 s for exactly this reason. It asserts a contract (non-expiring bearer token, 5 GiB allowance, seven-day window, no exp claim), never a latency budget. I gave it the same timeoutMs: 30000, with a comment recording why so nobody later reads it as an arbitrary bump.

I deliberately did not touch migrateDeviceIndexLayout or make ensureIndexes non-blocking — that is core logic belonging to the parent PR, it cannot be validated without a live MongoDB here, and a one-time index bootstrap is a cold-start cost, not a production defect.

Worth a follow-up on the parent though: registerUser awaits ensureIndexes() inline, even though collections.ts:1066 says "Ordinary API traffic never calls ensureIndexes". Registration does — so the first sign-up after an index-adding deploy pays the whole bootstrap synchronously, and the base branch materially increased that cost. Consider the same fire-and-forget treatment ensureCustomDataIndexes already uses for custom endpoints, or moving the migration to the explicit admin migration runner.

Your "Known baseline" note can be dropped

The description says test:collections fails on a contradiction where things_passkey_link_key_unique is both created by createThingsDataIndexes and listed in RETIRED_THINGS_INDEXES. That is already fixed at this head — your collections.ts change removes the entry from RETIRED_THINGS_INDEXES, and control-plane / Build + typecheck ratchet + unit tests (which runs test:collections, including indexBudget.test.ts) passes on b6155340.

Keeping the index was the right call, not retiring it: auth/passkeys.ts:511 upserts { thingtime: 'passkey-app-link', 'crystal.linkKey': linkKey } and relies on that unique partial index to resolve the update→insert race. On the base branch the contradiction meant every ensureIndexes() dropped and rebuilt a live uniqueness constraint — wasted work plus a window with no constraint. This PR closes both.

On the change itself — no objections

The federation scoping is the part I looked hardest at, and it is done properly: the federation id is bound into the signature payloads (peerSigningPayload, publicPeerSigningPayload, peerStreamSigningPayload), not merely filtered, and verifyPeerRequest rejects a wire id that differs from the local one before any comparison. Bumping PEER_DISCOVERY_PROTOCOL_VERSION to 2 correctly makes v1 signatures fail closed rather than silently degrade. announcePeer's $and of the signing-key $or and the federation $or pins both axes — a row can be adopted into a federation only while it has none (the legacy case, already gated upstream), never moved between them — and the post-write re-read asserts the match. listActivePeers filters on federationId with a matching { federationId, lastSeenAt, origin } index; query and index moved together.

I also checked that the new throw-on-unconfigured in signPeerRequest can't surface as an unhandled error on a request path: _peers.tsx is gated behind verifyPeerRequest (503) and _sync.tsx behind an explicit getDeploymentDataEnvironment() null check (503). Good.

Two notes, neither a blocker:

  • resolveSsoHub falls back to SSO_HUB_PRODUCTION for any environment it cannot classify. That is the safe direction for a real deployment, but it means a local or self-hosted server with no THINGTIME_DATA_ENV points sign-in at production. Deliberate per the "one release-window fallback" comment — just make sure the window actually closes.
  • accountHintOriginPresentation's labels are advisory; the comment already says a hint origin does not prove a data plane. Keep it that way.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🤝 Merged codex/thingtime-mcp-desktop-connectors into codex/account-hint-environment-labels — conflicts auto-resolved by Lopu's PR manager.

Conflicted files:

  • remix/CHANGELOG.md
  • remix/app/docs/apiDocs.ts
  • remix/package.json

Rebuilt the merged code graph first with graphify update ., then ran LLM semantic extraction (graphify extract ., claude-cli backend) and committed the result.

Please review the merge commit before relying on it.

github-actions Bot and others added 2 commits August 26, 2026 20:47
…environment-labels (AI-resolved conflicts)

Conflicted paths: remix/CHANGELOG.md, remix/app/docs/apiDocs.ts, remix/package.json

Resolved by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32986626169

Co-Authored-By: Lopu <github-actions[bot]@users.noreply.github.com>
Structural `graphify update` followed by LLM semantic `graphify extract` (graphify 0.9.4, claude-cli backend); unchanged content served from the tracked semantic cache.

Refreshed by the resolve-pr-conflicts workflow: https://github.com/lopugit/thingtime/actions/runs/32986626169
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Deterministic product contract advisory

These examples are warning-only. They do not fail the build/API contexts or block this PR.

  • ✅ Web CI required-context examples
  • ⚠️ Thin-listener topology examples (exit 1)
Sanitized tail
node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

AssertionError [ERR_ASSERTION]: all-branch.yml must stay retired; Lopu PR manager owns its former public triggers

true !== false

    at file:///home/runner/work/thingtime/thingtime/remix/scripts/workflow-caller-contract.mjs:39:10
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:681:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) {
  generatedMessage: false,
  code: 'ERR_ASSERTION',
  actual: true,
  expected: false,
  operator: 'strictEqual',
  diff: 'simple'
}

Node.js v22.23.2

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Lopu — correcting my 09:13 note: this job is red again, and I found a real (small) defect in the deploymentPeers index change

Two updates on this head (9ca92cb), one of which supersedes what I told you this morning.

1. Build + typecheck ratchet + unit tests is red again — and it still is not yours

At 09:13 I reported this job green. That was accurate for b615534. The base merge at 20:47 changed it:

✖ current Things index plan keeps four slots free below MongoDB hard limit
  AssertionError: Things index plan uses 62/64; reserve 4 slots for safe upgrades

I bisected it by running the guard against collections.ts at three points:

Commit Result
b615534 — this branch at 09:16 passes
f42abb9 — the base, after 126dc2d merged develop in at 12:31 62/64 — fail
9ca92cb — this head, after that base landed here at 20:47 62/64 — fail

I also ran the identical test with your collections.ts swapped for the base's: byte-identical failure, identical count. This PR adds zero things indexes — your only collections.ts change is on deploymentPeers, and un-retiring things_passkey_link_key_unique can only relax the second assertion, never the count.

I have posted the full breakdown on #68 (60 distinct things indexes + _id_ + the home-only TTL = 62; develop produces 49) since that is where the fix belongs. Nothing to do here — this clears when #68 clears. I did not attempt a fix on this branch: choosing which indexes to consolidate is #68's design decision, and landing it on a stacked child would conflict the moment #68 moves.

2. One real defect, fixed in this worktree

The new peers index is created with a bare createIndex:

col('deploymentPeers').createIndex({ federationId: 1, lastSeenAt: -1, origin: 1 }),

That changes the key shape of the index it replaces ({ lastSeenAt: -1, origin: 1 }), and MongoDB does not drop the old one — a plain createIndex with a new shape just builds a second index under a second auto-generated name. Fresh databases are fine; every already-deployed one keeps the superseded index forever, and nothing in the repo remembers it exists to retire it later.

This file already has the convention for exactly this — createIndexReplacing(collection, keys, { name }, legacyNames), used a dozen times above, with dropIndexRetrying treating an absent index as a no-op. Switched to it:

createIndexReplacing(
  col('deploymentPeers'),
  { federationId: 1, lastSeenAt: -1, origin: 1 },
  { name: 'deployment_peers_federation_recent' },
  ['lastSeenAt_-1_origin_1']
),

Naming it also makes it referenceable from a future retirement list instead of depending on an auto-generated name a second time. (I normalized the indentation back to the file's spaces while I was there — the new line had arrived with a stray tab.)

Given the 62/64 situation above, an orphaned index is worth more than usual right now — though note this one is on deploymentPeers, not things, so it does not move the failing count.

On the change itself — still no objections

I re-verified the federation work I praised this morning and it holds up. One thing I did not flag then and should have: announcePeer's filter admits { federationId: { $exists: false } }, so during the upgrade window the first v2 deployment to announce a given origin adopts that legacy row into its federation. Bounded by the pinned origin and the signingPublicKey match, so I do not think it is exploitable — but it is the one place the federation fence is deliberately open, and it deserves a comment saying it is a one-time migration allowance rather than a standing rule.

Also worth a remix/CHANGELOG.md line: PEER_DISCOVERY_PROTOCOL_VERSION 1 → 2 is a hard cutover, so v1 and v2 peers will 401 each other during a rolling deploy. Leases are 10 minutes so the mesh self-heals, but an operator seeing mutual 401s should be able to look that up rather than debug it.

— Lopu 🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant