Skip to content

fix(service-datasource): stop serving stored credentials on the datasource read path, and fix the false "credential-stripped" claim (#8081) - #8126

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-8081-datasource-credential-read-path
Aug 12, 2026
Merged

huangyiirene merged 1 commit into
mainfrom
claude/issue-8081-datasource-credential-read-path

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Closes nothing on its own — #8081 stays open until scope items 3/4/5 are dispositioned, and #7990 stays open behind it. Per the domain:services dispatch order (#8081 comment 5269562252), this PR delivers scope items 1 and 2 only; 3/4/5 are reported on the issue, not implemented.

Item 1 — read-path scrub (delivered)

DatasourceAdminService.getDatasource() returned the driver config verbatim (datasource-admin-service.ts:199) while :179 carried a doc comment promising "with the credential stripped" and describing config as "non-sensitive — credentials live in sys_secret, never in config". Nothing stripped anything.

The comment was not merely stale, it was load-bearing — it is why the gap survived a 26-surface credential survey. It is rewritten to say what the code does, and to record what it used to claim.

What is redacted, and where the list comes from. The refused-key set is derived from each driver's own contract rather than retyped: #8078 spells a refused inline credential as z.never(), so the schema is the list, and a credential key refused tomorrow is covered the day it lands. Three sources feed the scrub:

source keys why
derived (z.never()) password (postgres/mysql/mongodb), authToken (turso) #8078's own refusal shape
pre-#8078 aliases passwd pwd token jwt auth_token authtoken a stored row never met the parse that renamed them — the wizard persists through metadata.register
still-writable but credential-shaped turso encryptionKey AES-256 key the binder has no slot for; redacting on read neither grants nor removes that slot

A driver the platform ships no contract for still has the canonical spellings hidden by name. Declining to refuse an unrecognised key is a boundary choice about authoring; serving a key literally named password back in cleartext is a leak under any boundary.

URL-embedded credentials — the explicit reading the dispatch asked for. A postgresql://user:pass@host/db in config.url carries the same secret as config.password. Measured on origin/main, getDatasource() served both for the same row. A scrub stopping at config.password would have been half a scrub — the same "claims a protection it does not perform" shape this card exists to end. So the read path redacts the password component of the userinfo and leaves scheme, username and everything from the host onward byte-for-byte.

⛔ Nothing here refuses a URL, at any door. #7990 left that unruled and #8078 pinned the acceptance as a FACT; a pin guards that boundary and passes on both sides. Redacting a value on the way out is a different act from refusing it on the way in, and only the second one is unruled.

The response names what it withheld — redactedConfigKeys, the same courtesy hasSecret already pays for the bound handle.

Why the scrub needed an inverse. The edit form reads this config and patches it straight back, so a scrub with no inverse turns every untouched "Save" into silent credential deletion — trading a disclosure bug for a data-loss bug. updateDatasource therefore carries the hidden material forward on a same-driver round-trip, after the validation gate: the gate judges what the author wrote, and this is material the author never saw and is not asking to change. Same rule the credentialsRef beside it has always followed.

That also repairs a regression #8078 introduced, measured here for the first time: on main the form was served config.password verbatim, posted it back unchanged, and the write gate refused it — so editing any legacy datasource through the wizard answered 400 for a value the server itself had just supplied, including the active: false that takes a misconfigured datasource out of service.

Not changed: the stored record is never mutated. Redaction is a read-path act; the connect path reads the raw record and a legacy datasource keeps authenticating exactly as before. Getting cleartext out of the store is item 3's migration and is deliberately not attempted.

Item 2 — write-door measurement (measured; nothing to fix)

Measured end-to-end against the real HonoHttpServer, not reasoned from source:

door legacy inline payload reading
POST /api/v1/datasources (wizard) config.password 400 DATASOURCE_ADMIN_ERROR, full guidance in error.message
POST /api/v1/datasources alias passwd 400 DATASOURCE_ADMIN_ERROR, guidance + unrecognised-key prose
PATCH /api/v1/datasources/:name config.password 400 DATASOURCE_ADMIN_ERROR, full guidance
PUT /meta config.password 422 INVALID_METADATA + structured issues[] carrying the same message
datasource-admin plugin (service call) config.password throws with the full guidance; it is the object the routes wrap

No door collapses into a generic 500. The refusal guidance — naming both the secret binder and external.credentialsRef — reaches the caller intact at every door. Both codes are already ADR-0112-registered (DATASOURCE_ADMIN_ERROR, INVALID_METADATA), so no envelope change was needed and none was made. The card's worry did not materialise; that is the finding.

Reverse verification

9 of 21 pins fail on origin/main, each naming the defect it carries; 21/21 pass here. Method: the service file was reverted to origin/main's version in-tree and the suite re-run, so the readings are measured, not asserted.

Red on main: cleartext password served · URL credential served · redactedConfigKeys absent · turso authToken/encryptionKey served · alias spellings served · served-vs-stored split · round-trip 400 · non-credential edit blocked · driver change.

Green on both sides, by design: #8078's parse refusal still fires with its guidance intact (guards the spec half) · a URL-embedded credential is still ACCEPTED at the write door (guards the unruled boundary).

One pin was rewritten mid-task because it passed vacuously on both sides — "the stored record is untouched" is green on main for the worst possible reason (nothing redacts there, so served and stored are identical). It now asserts the served and stored halves together, which makes it red on main. That is the #7801 failure mode, caught here.

Gates

  • check:test-source-alias — OK, 72 packages scanned. New test file; no new workspace dependency, no KNOWN_UNALIASED_TEST_IMPORTS edit.
  • check:type-check-coverage — OK, 64/77 packages type-checked. service-datasource is type-checked, not ledgered; tsc --noEmit clean.
  • check:type-check-debt --re-measure — OK on the built closure, 1966 raw errors, none above its recorded number. (The surplus notices are pre-existing entries below their ceiling, untouched by this PR.)
  • service-datasource suite: 361/361.
  • Changeset present. Untouched: content/docs/releases/**, docs/adr/**, .claude/skills/**, skills/**, sys-session.object.ts, packages/spec/src/**.

Reported on the issue, NOT implemented here

Items 3 (migration story), 4 (turso encryptionKey) and 5 (managed credential slot) are decisions belonging to other seats. Two findings from measuring them are load-bearing and are written up on #8081:


Generated by Claude Code

…ource read path, and fix the false "credential-stripped" claim (#8081)

`getDatasource()` returned the driver `config` verbatim while its own doc
comment promised "with the credential stripped" and described `config` as
"non-sensitive — credentials live in `sys_secret`, never in config". Nothing
stripped anything. The comment was load-bearing: it is why the gap survived a
26-surface credential survey.

#8078 closed the WRITE door (`config.password` / `config.authToken` are
`z.never()` on every driver that has them) and deliberately left stored rows
alone. Those rows still hold cleartext, and `GET /api/v1/datasources/:name`
handed it to every caller.

The refused-key set is DERIVED from each driver's contract — #8078 spells a
refused inline credential as `z.never()`, so the schema is the list — plus the
pre-#8078 alias spellings (a stored row never met the parse that renamed them)
and turso's still-writable `encryptionKey`. A driver with no shipped contract
keeps the canonical spellings hidden by name.

A credential embedded in `config.url` is redacted too, at the password
component of the userinfo only. Refusing such a URL at the write door stays
UNRULED (#7990) and untouched: redacting on the way out is not refusing on the
way in, and a scrub that dropped `config.password` while serving the same
secret one key over would be a scrub in name only.

`updateDatasource` carries the hidden material forward across a same-driver
round-trip, after the validation gate rather than before it — the gate judges
what the author wrote, and this is material the author never saw. Without it
the scrub would have turned every untouched "Save" into silent credential
deletion. It also repairs a regression #8078 introduced and this card measured:
the form was served `config.password` verbatim, posted it back, and the gate
refused it — so editing any legacy datasource answered 400 for a value the
server itself had just supplied.

The stored record is never mutated; the connect path is unaffected.

Reverse verification: 9 of 21 pins fail on `origin/main` (each naming the
defect it carries), 21/21 pass here. The two guard pins — #8078's refusal with
its guidance intact, and URL credentials still ACCEPTED at the write door —
pass on both sides by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCoM9vVXw6yAVgEc4oio4m
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 5:03pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 1 changed package(s). ✅

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 17:21
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 37b82ed Aug 12, 2026
26 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-8081-datasource-credential-read-path branch August 12, 2026 17:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…authToken (objectstack-ai#8152) (objectstack-ai#8188)

After objectstack-ai#8078 a NEW turso datasource could not be authenticated by any route an
author has. objectstack-ai#7990/objectstack-ai#8078 made `config.authToken` a refused inline credential
(`z.never()`) at every authoring door, exactly like the SQL drivers'
`config.password`, and diverted the author to the secret binder: bind the
credential, keep only `external.credentialsRef` on the record. The connect path
resolves that ref and hands the cleartext to the driver factory as
`spec.secret` — and nothing on the turso path read it.
`TURSO_CONFIG_READERS.authToken` consulted `config.authToken` alone, so the
resolved secret was dropped and the connection was attempted unauthenticated.

`authToken` now reads `spec.secret` first and falls back to `config`. That is
exact parity with the postgres / mysql / mongodb arms in this same package
(`spec.secret ? { password: spec.secret } : cfg.password ? … : {}`) — no new
mechanism, no spec change, no second binder slot. `spec` was already on
`TursoConfigSource` for `schemaMode`, so the credential had been reaching this
function all along.

`config.authToken` stays readable, and not only for legacy stored rows: the CLI
and standalone hosts translate `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN`
into a `config` they construct themselves, which never meets the authoring
schema that refuses the key. An empty `spec.secret` is unset and falls through
to `config`, matching this builder's existing rule for string keys.

Pins, with their readings on origin/main (a0fdc56):

  RED on main (6)
   - turso-driver-config: bound secret reaches `authToken`; bound secret beats a
     stale inline `config.authToken`; binds to `authToken` only, never
     `encryptionKey`.
   - turso-bound-secret-authoring (new): a datasource created through the real
     admin door with a bound secret arrives at the driver with an `authToken`;
     the bound route is the ONLY one a newly authored datasource has (both
     halves in one case); the credential lands in one slot, not two.

  GREEN on main (15)
   - objectstack-ai#8078's inline refusal still fires at create AND at update, and its
     guidance still names both `external.credentialsRef` and the secret binder.
   - `config.authToken` is still read when no secret is bound (the host env
     route).
   - postgres / mysql / mongodb still read `spec.secret` as the password, and a
     bound secret still beats an inline `config.password` — the sibling arms
     this change takes its shape from, unpinned until now.

The gap was invisible because a stored row bypasses the parse and still
connects (only new authoring was dead) and because `turso-driver-config.test.ts`
had no `secret` case at all. Every red pin therefore starts at
`createDatasource()`, not at an existing record: the vacuous version of this
test is green on main.

`encryptionKey` deliberately untouched — a different secret, one binder slot,
and whether it needs a second is a separate decision. objectstack-ai#8126's read-time
redaction of it is untouched too.

Gates: check-test-source-alias OK (72 packages scanned, 62 registered;
`@objectstack/spec` was already the registry entry for this package, so no
widening); check:type-check-coverage OK (64/77 type-checked); the new test file
is type-checked (package `tsc --noEmit` clean). No refusal surface changed, so
no ADR-0112 envelope.

Tests: service-datasource 16/16 files 376/376 cases, runtime 145/145, cli
115/115.


Claude-Session: https://claude.ai/code/session_01Lw4Dm3qYuWFNJFMwQDdkzv

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…driver config.url at publish (objectstack-ai#8082) (objectstack-ai#8341)

* feat(spec)!: refuse URL-embedded credentials in driver config.url at publish (objectstack-ai#8082)

The objectstack-ai#7990 closure refused the inline credential keys; objectstack-ai#8078 measured and
pinned that config.url still accepted the identical secret one syntax over.
Maintainer-ruled Option A (2026-08-12): one value-level parse
(urlUserinfoPassword / credentialFreeUrl, data/driver/common.zod.ts) shared
by the four URL-bearing driver schemas — postgres/mysql/mongo url, turso
url + syncUrl — refuses a URL whose userinfo carries a non-empty password.

- Bare-user userinfo (user@host) stays accepted, matching objectstack-ai#7990's posture
  (username is a writable key; only the secret is refused) — and it is the
  exact shape the objectstack-ai#8126 read path serves for legacy rows, so an untouched
  Save keeps working.
- The refusal message names the working mechanisms (secret binder /
  external.credentialsRef), states the runtime-DSN carve-out explicitly
  (OS_DATABASE_URL never passes the publish door), and warns that
  ${...} placeholders resolve to nothing (objectstack-ai#8078, measured) instead of
  steering authors into that broken escape.
- The objectstack-ai#8078 acceptance pin (driver-credential-refusal.test.ts) is INVERTED
  to a rejection pin, not deleted; the objectstack-ai#8126 write-door acceptance pin in
  service-datasource flips the same way (its own comment said it waited on
  exactly this ruling).
- ADR-0087: D3 semantic entry datasource-config-url-userinfo-refused (no D2
  conversion — a credential cannot be mechanically encrypted into
  sys_secret); registry, spec-changes, upgrade guide, api-surface,
  export-origins, reference docs regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2

* test(spec): re-spell two driver URI fixtures credential-free (objectstack-ai#8082 fixture triage)

mongo.test.ts / postgres.test.ts 'accept config with connection URI' merely
used the userinfo spelling to demonstrate URI acceptance — re-spelled to the
bare-user form the rule still accepts (disposition: re-spell; the family
rejection pins live in driver-credential-refusal.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2

* chore(spec): regenerate artifact projections on the merged tree (os-regen step 4)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant