From 95369934ad8bd28928155d32e923cc6084f86539 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 02:38:27 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20export=20urlUserinfoUsername?= =?UTF-8?q?=20=E2=80=94=20the=20username=20half=20of=20the=20shared=20URL?= =?UTF-8?q?=20userinfo=20grammar=20(#8876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt --- .changeset/url-userinfo-username-accessor.md | 26 +++++ packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + .../datasource-credential-redaction.test.ts | 11 +++ packages/spec/src/data/driver/common.zod.ts | 94 +++++++++++++++---- .../driver/driver-credential-refusal.test.ts | 60 ++++++++++++ 6 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 .changeset/url-userinfo-username-accessor.md diff --git a/.changeset/url-userinfo-username-accessor.md b/.changeset/url-userinfo-username-accessor.md new file mode 100644 index 0000000000..46690f047c --- /dev/null +++ b/.changeset/url-userinfo-username-accessor.md @@ -0,0 +1,26 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): export `urlUserinfoUsername` — the username half of the shared URL userinfo grammar (#8876) + +`@objectstack/spec/data` owns the DSN userinfo grammar (`urlUserinfoPassword` / +`redactUrlPassword`, #8082/#8300) but exported only its password half. The +mongo DSN arm (#8696) must inject a bound `external.credentialsRef` secret via +`MongoClient`'s `auth` option, which requires the username the URL already +names — and reading it needs this grammar, because `new URL()` throws +`ERR_INVALID_URL` on the multi-host DSN form `MongoConfigSchema` documents +(`mongodb://app@h1:27017,h2:27017/app`, measured). A local copy in +`service-datasource` is the shape the #8082 single-parse ruling refuses by +name. + +**Additive only.** The new accessor shares the password half's boundary parse +by construction (both now call one internal RFC-3986 userinfo parse), returns +the RAW component (percent-encoding preserved, decoding stays with the +caller), answers `''` for an empty username inside present userinfo and +`undefined` when the string carries no userinfo at all, and still parses the +publish-refused `user:password@` shape correctly — stored legacy rows carry +it, and #8155's migration path must judge exactly those rows. No Zod schema +changes: every input that validated before validates identically after; the +read-path redaction alignment pin now covers the username half too (redaction +preserves the username byte-for-byte). diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index fb81969208..ca5e0d01b6 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -744,6 +744,7 @@ "unprovisionedInjectedColumns (function)", "urlCredentialQueryParams (function)", "urlUserinfoPassword (function)", + "urlUserinfoUsername (function)", "utcInstantMs (function)", "validateDriverConfig (function)", "valueSchemaFor (function)" diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index b3cc4691eb..0c3f1c3c84 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -744,6 +744,7 @@ "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", "urlCredentialQueryParams": "src/data/driver/common.zod.ts#urlCredentialQueryParams (function)", "urlUserinfoPassword": "src/data/driver/common.zod.ts#urlUserinfoPassword (function)", + "urlUserinfoUsername": "src/data/driver/common.zod.ts#urlUserinfoUsername (function)", "utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)", "validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)", "valueSchemaFor": "src/data/field-value.zod.ts#valueSchemaFor (function)" diff --git a/packages/spec/src/data/datasource-credential-redaction.test.ts b/packages/spec/src/data/datasource-credential-redaction.test.ts index 3e986ec81c..3f821867d3 100644 --- a/packages/spec/src/data/datasource-credential-redaction.test.ts +++ b/packages/spec/src/data/datasource-credential-redaction.test.ts @@ -32,6 +32,7 @@ import { getDriverConfigSchema, urlCredentialQueryParams, urlUserinfoPassword, + urlUserinfoUsername, } from './driver/index'; import { redactDatasourceConfig, @@ -181,6 +182,16 @@ describe('write-door alignment: redactUrlPassword removes exactly what urlUserin expect(redactUrlPassword(url)).toBe(url); } }); + + it('redaction preserves the USERNAME byte-for-byte — the #8876 half of the same alignment', () => { + // `urlUserinfoUsername` shares the password half's boundary parse by + // construction; this pins the redactor to the same grammar from the other + // side: stripping the password must never move or rewrite the username the + // #8696 injection path will read off the redacted/stored row. + for (const url of [...CARRYING, ...CREDENTIAL_FREE]) { + expect(urlUserinfoUsername(redactUrlPassword(url)), url).toBe(urlUserinfoUsername(url)); + } + }); }); describe('redactUrlCredentialQueryParams — the #8337 read half', () => { diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index 6bf4092298..ceaf6ca53d 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -160,32 +160,31 @@ export const URL_EMBEDDED_CREDENTIAL_REFUSED = (key: string): string => + 'not pass through this publish door and are unaffected.'; /** - * The password component of a URL-ish string's userinfo, or `undefined` when - * the string carries none — the shared value-level parse behind - * {@link credentialFreeUrl} (#8082). + * The userinfo component of a URL-ish string, or `undefined` when the string + * carries none — the ONE boundary parse behind both userinfo accessors + * ({@link urlUserinfoPassword}, {@link urlUserinfoUsername}), deliberately + * unexported so no caller can depend on a third reading of the grammar. * * Deliberately NOT `new URL()`: real DSNs take forms WHATWG parsing rejects or - * mangles (postgres/mongo multi-host `user:pass@h1:5432,h2:5432/db`, bare - * `:memory:`, `file:` paths), and a detector that throws on the exact inputs it - * must judge would fail open. The boundaries below are RFC 3986's, and match - * the read-path redactor (`redactUrlPassword` in this package's - * `data/datasource-credential-redaction.ts`, moved from `service-datasource` - * by #8300) so the write door refuses precisely the material the read door - * redacts: + * mangles (postgres/mongo multi-host `user:pass@h1:5432,h2:5432/db` throws + * `ERR_INVALID_URL`, bare `:memory:`, `file:` paths), and a parse that throws + * on the exact inputs it must judge would fail open. The boundaries below are + * RFC 3986's, and match the read-path redactor (`redactUrlPassword` in this + * package's `data/datasource-credential-redaction.ts`, moved from + * `service-datasource` by #8300) so the write door refuses precisely the + * material the read door redacts: * * - the authority is what follows `//` (scheme-relative included), up to the * first `/`, `?` or `#` — a `:` or `@` in a path or query is never userinfo; * - userinfo ends at the LAST `@` in the authority (a malformed literal `@` - * inside a password must not decide how much of it goes unjudged); - * - the password starts after the FIRST `:` in userinfo, and only a NON-EMPTY - * password is credential material (`user@host` and `user:@host` carry no - * secret; both stay accepted, and the second is what the read-path redaction - * of a legacy row round-trips as). + * inside a password must not decide how much of it goes unjudged). * * A string with no `//` (sqlite/turso `file:` paths, `:memory:`) has no - * authority and answers `undefined`. + * authority and answers `undefined`. The FIRST `:` inside the returned + * userinfo splits username from password; that split belongs to the two + * accessors, not here. */ -export function urlUserinfoPassword(value: string): string | undefined { +function urlUserinfo(value: string): string | undefined { const scheme = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i.exec(value); if (!scheme) return undefined; const rest = value.slice(scheme[0].length); @@ -193,13 +192,72 @@ export function urlUserinfoPassword(value: string): string | undefined { const authority = end === -1 ? rest : rest.slice(0, end); const at = authority.lastIndexOf('@'); if (at === -1) return undefined; - const userinfo = authority.slice(0, at); + return authority.slice(0, at); +} + +/** + * The password component of a URL-ish string's userinfo, or `undefined` when + * the string carries none — the shared value-level parse behind + * {@link credentialFreeUrl} (#8082). + * + * Boundaries are {@link urlUserinfo}'s (RFC 3986, aligned with the read-path + * redactor — see that parse for why WHATWG parsing is refused here); on top of + * them: + * + * - the password starts after the FIRST `:` in userinfo, and only a NON-EMPTY + * password is credential material (`user@host` and `user:@host` carry no + * secret; both stay accepted, and the second is what the read-path redaction + * of a legacy row round-trips as). + */ +export function urlUserinfoPassword(value: string): string | undefined { + const userinfo = urlUserinfo(value); + if (userinfo === undefined) return undefined; const colon = userinfo.indexOf(':'); if (colon === -1) return undefined; const password = userinfo.slice(colon + 1); return password.length > 0 ? password : undefined; } +/** + * The username component of a URL-ish string's userinfo, or `undefined` when + * the string carries no userinfo at all — the other half of the grammar behind + * {@link urlUserinfoPassword}, sharing {@link urlUserinfo}'s boundary parse so + * the two halves cannot drift (#8876; the #8082 ruling names a single + * value-level parse precisely so no second copy exists to disagree with this + * one). + * + * The consumer this exists for is credential *injection*, not refusal: a + * driver arm that binds a secret via `external.credentialsRef` against a DSN + * (`MongoConfigSchema.url`'s declared contract) must hand its client the + * username the URL already names — and reading it needs this grammar, because + * `new URL()` rejects the multi-host DSN form outright (#8696). Hence two + * deliberate asymmetries with the password half: + * + * - a `user:password@` URL is REFUSED at publish ({@link credentialFreeUrl}) + * but parsed correctly here — stored legacy rows still carry that shape, + * and an accessor that went blind on exactly those rows would misread the + * inputs the migration path (#8155) must judge; + * - an EMPTY username inside present userinfo answers `''`, distinct from + * the `undefined` of no userinfo — `:pass@host` and `host` are different + * facts, and the caller deciding whether an empty username is usable must + * be able to tell them apart. Only a non-empty PASSWORD was credential + * material; the username is not credential material, so no such collapse + * applies. + * + * The value is the RAW component, percent-encoding preserved — the same + * convention as the password half and the byte-level alignment pin + * (`datasource-credential-redaction.test.ts`). A client that expects a + * decoded value (`mongodb`'s `auth.username` does) decodes at the point of + * use; decoding is value interpretation, not boundary grammar, so it stays + * with the caller. + */ +export function urlUserinfoUsername(value: string): string | undefined { + const userinfo = urlUserinfo(value); + if (userinfo === undefined) return undefined; + const colon = userinfo.indexOf(':'); + return colon === -1 ? userinfo : userinfo.slice(0, colon); +} + /** * Per-driver credential-bearing URL QUERY PARAMETER names (#8337) — the third * spelling of the same secret, one syntax over from userinfo (#8082), which was diff --git a/packages/spec/src/data/driver/driver-credential-refusal.test.ts b/packages/spec/src/data/driver/driver-credential-refusal.test.ts index a298aee5f3..2e72751886 100644 --- a/packages/spec/src/data/driver/driver-credential-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -31,6 +31,7 @@ import { CREDENTIAL_URL_QUERY_PARAMS, urlCredentialQueryParams, urlUserinfoPassword, + urlUserinfoUsername, } from './common.zod'; import { getMongoConfigJsonSchema, @@ -534,3 +535,62 @@ describe('urlUserinfoPassword — the shared value-level parse (#8082)', () => { expect(urlUserinfoPassword('postgres://:p@h/db')).toBe('p'); }); }); + +describe('urlUserinfoUsername — the username half of the same grammar (#8876)', () => { + it('judges the multi-host DSN forms `new URL()` rejects — the reason this helper exists (#8696)', () => { + // `new URL('mongodb://app@h1:27017,h2:27017/app')` throws ERR_INVALID_URL + // (measured in the filing); the accessor must judge it, not fail open. + expect(urlUserinfoUsername('mongodb://app@h1:27017,h2:27017/app')).toBe('app'); + expect(urlUserinfoUsername('postgresql://u:p@h1:5432,h2:5432/db')).toBe('u'); + }); + + it('a `user:pass@` URL is refused at publish but PARSED correctly here — stored legacy rows carry it', () => { + // The publish door (`credentialFreeUrl`) refuses this shape; the accessor + // still reads it, because #8155's URL-bearing legacy rows are exactly the + // inputs the migration path must judge. + expect(urlUserinfoPassword('mongodb://app:hunter2@h/db')).toBe('hunter2'); + expect(urlUserinfoUsername('mongodb://app:hunter2@h/db')).toBe('app'); + }); + + it('an EMPTY username inside present userinfo is `""`, distinct from the `undefined` of no userinfo', () => { + expect(urlUserinfoUsername('mongodb://:p@h/db')).toBe(''); + expect(urlUserinfoUsername('mongodb://h:27017/db')).toBeUndefined(); + expect(urlUserinfoUsername('postgres://:@h/db')).toBe(''); + }); + + it('a bare username and a trailing-colon username both answer the username', () => { + expect(urlUserinfoUsername('postgres://svc@h/db')).toBe('svc'); + // `user:@host` — what the read-path redaction of a legacy row round-trips as. + expect(urlUserinfoUsername('postgres://svc:@h/db')).toBe('svc'); + }); + + it('returns the RAW component — percent-encoding preserved for the caller to decode at use', () => { + expect(urlUserinfoUsername('mongodb://app%40corp:x@h/db')).toBe('app%40corp'); + expect(urlUserinfoUsername('mongodb://app%40corp@h/db')).toBe('app%40corp'); + }); + + it('username ends at the FIRST `:`; userinfo ends at the LAST `@` — same boundaries as the password half', () => { + // Malformed literal `@` in the password must not change how much of the + // userinfo is judged, on either half. + expect(urlUserinfoUsername('postgres://u:p@ss@host/db')).toBe('u'); + // A `:` inside the password does not move the username split. + expect(urlUserinfoUsername('postgres://u:p:q@h/db')).toBe('u'); + }); + + it('a colon or `@` in a path, query or fragment is never userinfo', () => { + expect(urlUserinfoUsername('https://host/a:b@c')).toBeUndefined(); + expect(urlUserinfoUsername('https://host/p?to=a:b@c')).toBeUndefined(); + expect(urlUserinfoUsername('https://host/p#a:b@c')).toBeUndefined(); + }); + + it('non-authority strings carry no userinfo: file paths, :memory:, bare words', () => { + for (const value of ['file:./data/objectstack.db', ':memory:', 'public', 'a:b@c']) { + expect(urlUserinfoUsername(value), value).toBeUndefined(); + } + }); + + it('scheme-relative URLs are judged too', () => { + expect(urlUserinfoUsername('//u:p@h/db')).toBe('u'); + expect(urlUserinfoUsername('//h/db')).toBeUndefined(); + }); +}); From 16ea5d36b7bdd9e4fb41fad5725c645696b04321 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 02:47:12 +0000 Subject: [PATCH 2/2] chore(spec): regenerate api-surface/export-origins shards on the merged tree (os-regen deferral) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt --- packages/spec/api-surface/data.json | 4 ++++ packages/spec/export-origins/data.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index ca5e0d01b6..fcda339ab9 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -189,6 +189,8 @@ "DocumentVersion (type)", "DocumentVersionParsed (type)", "DocumentVersionSchema (const)", + "DottedFilterHeadClass (type)", + "DottedFilterHeadMeta (interface)", "DriverCapabilities (type)", "DriverCapabilitiesSchema (const)", "DriverConfig (type)", @@ -498,6 +500,7 @@ "RowCrudActionOverrideParsed (type)", "RowCrudActionOverrideSchema (const)", "RowCrudPredicates (interface)", + "SCALAR_FILTER_HEAD_TYPES (const)", "SCHEMA_MODE_BELONGS_ON_DATASOURCE (const)", "SEARCHABLE_ENUM_TYPES (const)", "SEARCHABLE_TEXTUAL_TYPES (const)", @@ -627,6 +630,7 @@ "canonicalizeSqlType (function)", "checkLiteralDefaultValue (function)", "checkManagedApiMethodAffordances (function)", + "classifyDottedFilterHead (function)", "classifyFilterToken (function)", "containsUnresolvedPlaceholder (function)", "countAuthorableFields (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 0c3f1c3c84..ffbaea8c9b 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -189,6 +189,8 @@ "DocumentVersion": "src/data/document.zod.ts#DocumentVersion (type)", "DocumentVersionParsed": "src/data/document.zod.ts#DocumentVersionParsed (type)", "DocumentVersionSchema": "src/data/document.zod.ts#DocumentVersionSchema (const)", + "DottedFilterHeadClass": "src/data/filter-dotted-head.ts#DottedFilterHeadClass (type)", + "DottedFilterHeadMeta": "src/data/filter-dotted-head.ts#DottedFilterHeadMeta (interface)", "DriverCapabilities": "src/data/driver.zod.ts#DriverCapabilities (type)", "DriverCapabilitiesSchema": "src/data/driver.zod.ts#DriverCapabilitiesSchema (const)", "DriverConfig": "src/data/driver.zod.ts#DriverConfig (type)", @@ -498,6 +500,7 @@ "RowCrudActionOverrideParsed": "src/data/object.zod.ts#RowCrudActionOverrideParsed (type)", "RowCrudActionOverrideSchema": "src/data/object.zod.ts#RowCrudActionOverrideSchema (const)", "RowCrudPredicates": "src/data/object.zod.ts#RowCrudPredicates (interface)", + "SCALAR_FILTER_HEAD_TYPES": "src/data/filter-dotted-head.ts#SCALAR_FILTER_HEAD_TYPES (const)", "SCHEMA_MODE_BELONGS_ON_DATASOURCE": "src/data/driver/common.zod.ts#SCHEMA_MODE_BELONGS_ON_DATASOURCE (const)", "SEARCHABLE_ENUM_TYPES": "src/data/search-fields.ts#SEARCHABLE_ENUM_TYPES (const)", "SEARCHABLE_TEXTUAL_TYPES": "src/data/search-fields.ts#SEARCHABLE_TEXTUAL_TYPES (const)", @@ -627,6 +630,7 @@ "canonicalizeSqlType": "src/data/type-compat.ts#canonicalizeSqlType (function)", "checkLiteralDefaultValue": "src/data/default-value-shape.ts#checkLiteralDefaultValue (function)", "checkManagedApiMethodAffordances": "src/data/managed-api-affordance.ts#checkManagedApiMethodAffordances (function)", + "classifyDottedFilterHead": "src/data/filter-dotted-head.ts#classifyDottedFilterHead (function)", "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)", "containsUnresolvedPlaceholder": "src/data/driver/common.zod.ts#containsUnresolvedPlaceholder (function)", "countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)",