Skip to content

fix(identity): generalize #2730 fix — route all user-scoped handlers through tiered resolver - #2736

Merged
jung-thomas merged 4 commits into
DEVfrom
fix/identity-resolution-generalization-2730
Oct 13, 2026
Merged

jung-thomas merged 4 commits into
DEVfrom
fix/identity-resolution-generalization-2730

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

Problem

Follow-up to #2730 / PR #2734 (the getMyFavorites fix, already merged to DEV). That fix revealed a systemic pattern: ~16 user-scoped handlers in srv/developer-service.js resolved the DB user via resolveUserSapId + SELECT where({ sapId }) only. For social / IAS / GitHub logins (the Devtoberfest/TechEd audience), Users.sapId is NULL — identity lives in the (issuer, subject) link (UserIdentities, #2641 / GitHub OIDC shim). So every one of these handlers silently returned empty/zero (reads) or 401/duplicate-row (writes) for those users. Favorites was just the first symptom.

Fix

Two reusable helpers in packages/core/resolve-db-user.js, and every user-scoped site routed through them:

  • resolveDbUserScoped(req, cols?) — read-only tiered resolve (pin → (iss,sub) link → sapId). 9 reads: getProgress, findTaskProgressByUserAndTasksIds, countCompletedMissionsTotal/Percent, getEventProgress, getAppSpaceProgress, the two before('READ') row filters (TaskRecords, LearningPreferences — fail-closed preserved), getKhorosProfile.
  • getOrProvisionDbUser(req, {sapId}, cols?) — write get-or-create; prefers tiered provisionDbUser when a token is present, falls back to the sapId path only for the token-less MCP *For path. 7 writes + the doCompleteStep/doResetTutorialProgress shared helpers; deletes ~4 duplicated hand-rolled "mint a Users row" blocks (net −63 lines in developer-service.js).

Both helpers added to the @tutorials/core barrel so they land in the production _shared bundle (regenerated at deploy via build:all → prebuild:shared-bundle).

Why so many places (root cause of the pattern)

resolveUserSapId is a claim extractor, reused as if it were a user resolver. The real resolver (provisionDbUser/resolveUser) arrived with the IAS migration (#2641) but developer-service.js was never swept. This PR completes that sweep and centralizes the pattern so it can't drift back.

Testing

  • 3 new unit suites (16 tests) drive null-sapId (iss,sub) identity-link users through the real handlers via srv.tx: reads return the user's data (not empty), writes land on the canonical Users.ID with no duplicate row, MCP token-less path still uses the sapId branch.
  • Full identity suite 24/24 green (incl. the merged favorites tests).
  • hasToken narrowed to drop the emailFromUser(user.id) false-positive (it was minting duplicate null-sapId rows for legacy sapId-keyed users). Verified safe: every real OIDC/IAS/GitHub-shim login carries iss+sub; only basic-auth/MCP service users (which must take the sapId branch) are affected.

Scope notes

  • Pre-existing DEV test failures (content-delta-read/dualwrite, page-serve-handler, embedding-pipeline, …) are unrelated — their test + SUT files are byte-identical to origin/DEV. Not touched here.
  • Known latent follow-up (out of scope): neither helper consults the pinned attr.dbUserId; a NULL-sapId PAT reaching a non-*For write handler would mint a spurious sapId=email row. No PAT write tool routes there today; worth a tracking issue when PAT write tools land.

Plan + full review ledger: docs/superpowers/plans/2026-10-12-identity-resolution-fix.md.

🤖 Generated with Claude Code

…2730)

Task 2: replace the old resolveUserSapId + sapId-only SELECT pattern in the 9
bucket-A READ handlers with resolveDbUserScoped, so null-sapId IAS/social/GitHub
users (whose identity is tied to a UserIdentities (iss,sub) link, not a sapId)
see their own data instead of empty/zero fallbacks.

Handlers converted: getProgress, findTaskProgressByUserAndTasksIds,
countCompletedMissionsTotal, countCompletedMissionsPercent, getEventProgress,
getAppSpaceProgress, before('READ','TaskRecords'), before('READ','LearningPreferences'),
getKhorosProfile. The two before('READ') filters preserve their fail-closed
req.query.where predicate on miss. getAppSpaceProgress.authenticated now uses
Boolean(dbUser) instead of Boolean(sapId) (#2314 intent preserved).

Also adds a fallback in srv/lib/resolve-db-user.js to load resolveDbUserScoped
and getOrProvisionDbUser directly from packages/core when the workspace
@tutorials/core node_modules copy predates these helpers (worktree isolation).

TDD: new test/unit/developer-service-identity-reads.test.js covers all 9 handlers
with a null-sapId identity-link user; 6/9 failed pre-fix, all 9 pass post-fix.
…; revert runtime hack (#2730)

Fix-round 1 for Task 2. The runtime fallback in srv/lib/resolve-db-user.js
(importing directly from ../../packages/core/) masked a missing barrel export:
resolveDbUserScoped and getOrProvisionDbUser were added to
packages/core/resolve-db-user.js in Task 1 but never added to the packages/core
barrel (index.js). This means:
  - @tutorials/core subpath imports returned undefined for these two functions
  - esbuild's core.bundle.mjs (built from index.js) omitted them entirely
  - On CF deploy (where packages/ is absent), every converted handler would have
    thrown "resolveDbUserScoped is not a function"

Fix:
  1. Add both names to the barrel export line in packages/core/index.js
  2. Revert srv/lib/resolve-db-user.js to plain mod.resolveDbUserScoped /
     mod.getOrProvisionDbUser — the bundle now has them, so no workaround needed
  3. Regenerated all three core bundles (srv/lib/_shared/, srv-qa/lib/_shared/,
     srv-mcp/lib/_shared/) — grep counts: resolveDbUserScoped=2, getOrProvisionDbUser=2
     (bundles are gitignored; regenerate with `node scripts/bundle-shared.cjs`)

Verification: `grep -c resolveDbUserScoped srv/lib/_shared/core.bundle.mjs` → 2 (was 0)
All 11 tests pass: developer-service-identity-reads (9) + resolve-db-user-scoped (2).
… drop duplicated mint blocks (#2730)

- doCompleteStep: replace sapId SELECT + hand-rolled mint with getOrProvisionDbUser
- doResetTutorialProgress: same; preserve no-op-on-miss (returns { newAttemptNumber:1 })
- createTaskRecord: use resolveDbUserScoped (find-only legacy endpoint, 404 on miss)
- setLearningPreferences: use getOrProvisionDbUser (removes sapId null guard + mint)
- setPreferredEventRegion: same
- setKhorosLink: use getOrProvisionDbUser inside the persist try-block; remove outer sapId guard
- clearKhorosLink: use resolveDbUserScoped (clear-only, no-op on miss)

Narrows hasToken in getOrProvisionDbUser to exclude emailFromUser(user)'s user.id
fallback — a basic-auth user with email-shaped user.id (e.g. the #1614 overflow
regression test) must NOT trip the provisionDbUser path. Only real OIDC signals
(iss+sub or explicit attr.email/name) qualify as "token present".

Also fixes the resolveDbUserScoped @PARAM docstring (was 'uses req.user + cds.tx(req)';
impl is 'threads req.tx when present, falls back to ambient otherwise').

TDD: test/unit/developer-service-identity-writes.test.js verifies:
  1. Token user (iss+sub, null sapId) → updates existing identity-link row, no dup minted
  2. setPreferredEventRegion, setKhorosLink, clearKhorosLink — no 401 for null-sapId user
  3. Token-less MCP user (bare user.id, no authInfo/attr) → hasToken=false, sapId branch fires

All 81 regression tests pass (reset/complete/khoros/mcp/khoros/puzzle suites).
@jung-thomas
jung-thomas merged commit 1872412 into DEV Oct 13, 2026
4 of 5 checks passed
@jung-thomas
jung-thomas deleted the fix/identity-resolution-generalization-2730 branch October 13, 2026 06:55
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