Skip to content

Commit e5a180c

Browse files
committed
sea-auth-u2m: round-3 fixup — namespace kernel metadata, dedupe predicate, type-guard envelope, treat blank secret as U2M
Addresses devils-advocate-auth-u2m-2 round-1 findings on commit 98d5ecf. NF-N1 is a real bug (collision between the kernel envelope's textual `errorCode` field and the pre-existing enum-typed `errorCode` on `OperationStateError` / `RetryError`). NF-N2..NF-N4 are mediums. Includes B-4 collapse (one defineProperty helper for both top-level sqlState and the kernel metadata namespace). ## NF-N1 (HIGH) — namespace kernel metadata + B-4 collapse Before this commit, `decodeNapiKernelError` `defineProperty`d each of the 5 kernel envelope fields (`errorCode`, `vendorCode`, `httpStatus`, `retryable`, `queryId`) directly on the JS error. But `OperationStateError.ts:21` and `RetryError.ts:12` already declare a top-level `errorCode: enum` field, and `DBSQLOperation.ts:209` switches on `err.errorCode === OperationStateErrorCode.Canceled`. A Cancelled kernel envelope with `errorCode: "USER_REQUESTED_CANCEL"` would clobber the enum string `'CANCELED'`, silently breaking cancel detection. Going with option (a) from team-lead's three remediation paths: nest the 5 kernel envelope fields under a single `error.kernelMetadata.*` namespace. Clean separation, no surprise, matches the way `attachSqlState`'s pattern keeps `sqlState` at the top level (which is collision-free). Folded B-4 simultaneously: replaced the two helpers (`attachSqlState`, `attachMetadata`) with one `defineErrorMetadata(error, key, value)` that owns the `defineProperty` flags. Both `sqlState` (top-level) and `kernelMetadata` (namespaced) go through the same helper now. ## NF-N2 (medium) — dedupe e2e `looksReal` against production `auth-m2m-e2e.test.ts:58-62` had a `looksReal` predicate that was still case-sensitive even though round-2's `isBlankOrReserved` is case-insensitive. Exported `isBlankOrReserved` from `SeaAuth.ts` and imported it in the e2e test. Eliminates the predicate-drift risk (also resolves the bloat-watchdog's B-3). ## NF-N3 (medium) — blank/reserved oauthClientSecret routes to U2M A user passing `oauthClientSecret: process.env.MY_SECRET || ''` previously hit the M2M arm's "secret must be non-empty" rejection, which never mentions U2M. Now blank/whitespace/reserved-literal secrets route to the U2M arm — where if `oauthClientId` is also set, the dedicated "not supported on U2M" rejection fires (round-2 NF-2 work). The error message correctly points at the right flow. Updated 5 existing test cases that had assumed the old M2M-rejects behavior; they now assert the U2M-via-id-rejection path. Added 3 new cases (empty string, whitespace-only, literal `'undefined'` routing to U2M happy path when no clientId is set). ## NF-N4 (medium) — per-field envelope type-guards `decodeNapiKernelError` previously cast `parsed` to a typed shape without runtime-validating the 5 optional fields. A kernel bug that emits `retryable: "true"` (string) instead of `true` (boolean) would propagate the wrong-typed property to JS callers. Added a `buildKernelMetadata(parsed: Record<string, unknown>)` helper that checks `typeof` per-field and discards mis-typed values. New unit test verifies all 5 wrong-type variants are dropped while a single correctly-typed field survives. Also: when the parsed envelope has no validated metadata fields, the decoder now omits the `kernelMetadata` namespace entirely (rather than attaching `{}`-shaped noise). Pinned by a new unit test. ## DEFERRED to Phase 2 - NF-N5 (low — SeaNativeLoader top-level require): per team-lead's guidance, defer to Phase 2 (deploy-time visibility issue). - Language-expert-auth-u2m-2's 1 medium + 6 low. ## Kernel fix consumption note team-lead's message indicated kernel-author landed the Error::io() → Error::unauthenticated() fix on `krn-napi-binding` at commit `a64479a`. My napi binding's path-dep (`native/sea/Cargo.toml`) points to `../../../../databricks-sql-kernel-sea-WT/async-public-api`, not `krn-napi-binding`. As of the round-3 build, `async-public-api` still has the OLD `Error::io()` at `m2m.rs:270`. So my rebuild this round picks up the new TS code only — NOT the kernel error- class fix. Consequence for the bad-secret e2e: it would STILL fail with HiveDriverError (not AuthenticationError) on a live run today, because the kernel envelope on the worktree my path-dep reaches still carries `code: "Internal"`. The kernel author's fix needs to land on `async-public-api` (the branch my path-dep tracks), or my path-dep needs to point at `krn-napi-binding`. Flagging to team-lead in the reply. Tests: - Unit: 85/85 pass (was 79 before this commit: +6 net — added 4 new cases for NF-N3 routing + NF-N1 collision + NF-N4 type-guard + NF-N4 metadata-omitted; flipped 3 existing M2M-rejection cases to U2M-rejection-via-id; updated 2 NF-4 metadata tests to read through the new namespace). - TypeScript build: clean. - Native build: cached (no Rust changes from this commit). Co-authored-by: Isaac
1 parent 6448fc8 commit e5a180c

5 files changed

Lines changed: 271 additions & 114 deletions

File tree

lib/sea/SeaAuth.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,11 @@ function prependSlash(str: string): string {
8484
* shell exports (e.g. `export FOO="$UNSET_VAR"`) produce. Surfacing
8585
* these here means an OAuth flow's `invalid_client` from the workspace
8686
* is always a real credential mismatch, never a malformed-input passthrough.
87+
*
88+
* Exported so the integration-test env-gate can reuse the same predicate
89+
* and stay in lockstep with production (B-3 fix).
8790
*/
88-
function isBlankOrReserved(s: string): boolean {
91+
export function isBlankOrReserved(s: string): boolean {
8992
const normalized = s.trim().toLowerCase();
9093
return normalized.length === 0 || normalized === 'undefined' || normalized === 'null';
9194
}
@@ -180,7 +183,14 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
180183

181184
// Flow selector mirrors thrift's `DBSQLClient.createAuthProvider`
182185
// (`DBSQLClient.ts:143`): `oauthClientSecret defined ? M2M : U2M`.
183-
if (oauth.oauthClientSecret === undefined) {
186+
// Blank or buggy-shell-export secrets route to U2M (rather than
187+
// M2M-with-bad-secret) so the error message correctly points the user
188+
// at the right flow. `oauthClientSecret: process.env.MY_SECRET || ''`
189+
// is a common shape; routing it to the M2M arm would surface an
190+
// M2M-specific error that never mentions U2M.
191+
const secretIsBlank =
192+
typeof oauth.oauthClientSecret === 'string' && isBlankOrReserved(oauth.oauthClientSecret);
193+
if (oauth.oauthClientSecret === undefined || secretIsBlank) {
184194
// U2M.
185195
if (oauth.oauthClientId !== undefined) {
186196
// The kernel hardcodes `client_id = "databricks-cli"` for U2M;

lib/sea/SeaErrorMapping.ts

Lines changed: 90 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -52,31 +52,52 @@ export type KernelErrorCode =
5252
| 'SqlError';
5353

5454
/**
55-
* An `Error` with a preserved SQLSTATE on the `sqlState` property. Used as the
56-
* narrowed return type of {@link mapKernelErrorToJsError} so callers that need
57-
* the SQLSTATE can `error.sqlState` without an `any` cast.
55+
* Optional metadata fields the kernel may attach via the
56+
* `__databricks_error__:` envelope (per `native/sea/src/error.rs:50-89`).
57+
*
58+
* `errorCode` is namespaced under `kernelMetadata` rather than placed at
59+
* the top level because two existing JS-side error classes
60+
* (`OperationStateError`, `RetryError`) already declare a top-level
61+
* `errorCode: enum` field, and `DBSQLOperation.ts:209` switches on it
62+
* (`err.errorCode === OperationStateErrorCode.Canceled`). Top-level
63+
* defineProperty would clobber that enum with a kernel string and break
64+
* cancel/close detection.
65+
*/
66+
export interface KernelMetadata {
67+
errorCode?: string;
68+
vendorCode?: number;
69+
httpStatus?: number;
70+
retryable?: boolean;
71+
queryId?: string;
72+
}
73+
74+
/**
75+
* An `Error` carrying optional SEA-side kernel context. `sqlState` is
76+
* exposed at the top level (no collision in the existing driver error
77+
* tree); the remaining envelope fields live under a `kernelMetadata`
78+
* namespace to avoid clobbering pre-existing `errorCode` semantics on
79+
* `OperationStateError` / `RetryError`.
5880
*/
5981
export interface ErrorWithSqlState extends Error {
6082
sqlState?: string;
83+
kernelMetadata?: KernelMetadata;
6184
}
6285

6386
/**
64-
* Attach the kernel's SQLSTATE to the JS error object via the `sqlState` property.
65-
* The driver has no pre-existing `sqlState` convention (no other error class
66-
* sets it today) so this single helper defines it for the SEA path.
87+
* Attach a non-enumerable own-property to the error. The shape matches
88+
* Node's convention for attaching `.code` to system errors:
89+
* non-enumerable (clean `JSON.stringify`) but readable via direct
90+
* property access and `Object.getOwnPropertyDescriptor`. One helper for
91+
* both the top-level `sqlState` and the namespaced `kernelMetadata`
92+
* object so the `defineProperty` flags live in exactly one place.
6793
*/
68-
function attachSqlState(error: ErrorWithSqlState, sqlstate?: string): ErrorWithSqlState {
69-
if (sqlstate !== undefined) {
70-
// Using Object.defineProperty so the property is non-enumerable but still
71-
// visible via direct access — matches the way Node attaches `.code` to system errors.
72-
Object.defineProperty(error, 'sqlState', {
73-
value: sqlstate,
74-
writable: true,
75-
enumerable: false,
76-
configurable: true,
77-
});
78-
}
79-
return error;
94+
function defineErrorMetadata<K extends string, V>(error: Error, key: K, value: V): void {
95+
Object.defineProperty(error, key, {
96+
value,
97+
writable: true,
98+
enumerable: false,
99+
configurable: true,
100+
});
80101
}
81102

82103
/**
@@ -146,37 +167,37 @@ export function mapKernelErrorToJsError(kErr: KernelErrorShape): ErrorWithSqlSta
146167
break;
147168
}
148169

149-
return attachSqlState(error, sqlstate);
170+
if (sqlstate !== undefined) {
171+
defineErrorMetadata(error, 'sqlState', sqlstate);
172+
}
173+
return error;
150174
}
151175

152176
/**
153-
* Optional metadata fields the kernel may attach via the
154-
* `__databricks_error__:` envelope (per `native/sea/src/error.rs:50-89`).
155-
* Attached to the decoded JS error as non-enumerable own-properties so
156-
* callers can read them (e.g. `error.httpStatus`) without polluting
157-
* `JSON.stringify(error)` output. Matches the way Node attaches
158-
* `.code` to system errors and the way `attachSqlState` works above.
177+
* Build a {@link KernelMetadata} object from a parsed envelope, applying
178+
* per-field type validation. A kernel-side bug that emits, say,
179+
* `retryable: "true"` (string) instead of `true` (boolean) would
180+
* otherwise leak the wrong-typed value through to JS callers; the
181+
* type-guard discards the malformed field rather than passing it through.
159182
*/
160-
interface KernelErrorMetadata {
161-
errorCode?: string;
162-
vendorCode?: number;
163-
httpStatus?: number;
164-
retryable?: boolean;
165-
queryId?: string;
166-
}
167-
168-
function attachMetadata(error: Error, meta: KernelErrorMetadata): void {
169-
for (const key of ['errorCode', 'vendorCode', 'httpStatus', 'retryable', 'queryId'] as const) {
170-
const value = meta[key];
171-
if (value !== undefined) {
172-
Object.defineProperty(error, key, {
173-
value,
174-
writable: true,
175-
enumerable: false,
176-
configurable: true,
177-
});
178-
}
183+
function buildKernelMetadata(parsed: Record<string, unknown>): KernelMetadata {
184+
const meta: KernelMetadata = {};
185+
if (typeof parsed.errorCode === 'string') {
186+
meta.errorCode = parsed.errorCode;
187+
}
188+
if (typeof parsed.vendorCode === 'number') {
189+
meta.vendorCode = parsed.vendorCode;
179190
}
191+
if (typeof parsed.httpStatus === 'number') {
192+
meta.httpStatus = parsed.httpStatus;
193+
}
194+
if (typeof parsed.retryable === 'boolean') {
195+
meta.retryable = parsed.retryable;
196+
}
197+
if (typeof parsed.queryId === 'string') {
198+
meta.queryId = parsed.queryId;
199+
}
200+
return meta;
180201
}
181202

182203
/**
@@ -186,11 +207,12 @@ function attachMetadata(error: Error, meta: KernelErrorMetadata): void {
186207
* - Structured kernel error: `Error.message` starts with
187208
* {@link ERROR_SENTINEL} followed by a JSON envelope. We strip the
188209
* sentinel, parse the JSON, route the {@link KernelErrorShape}
189-
* through {@link mapKernelErrorToJsError}, and attach all remaining
190-
* envelope fields (`errorCode`, `vendorCode`, `httpStatus`,
191-
* `retryable`, `queryId`) as non-enumerable own-properties on the
192-
* returned error. Thrift parity demand: thrift errors carry these
193-
* fields, so SEA errors must too.
210+
* through {@link mapKernelErrorToJsError}, and attach the remaining
211+
* envelope fields under a single non-enumerable `kernelMetadata`
212+
* namespace. Namespacing avoids the collision with
213+
* `OperationStateError.errorCode` (an enum) and `RetryError.errorCode`
214+
* (an enum), each of which is already switched on at the JS layer
215+
* (see `DBSQLOperation.ts:209`).
194216
* - Binding-side error (e.g. `napi::Error::new(InvalidArg, "openSession:
195217
* \`token\` is required for the requested auth mode")` produced by
196218
* the binding's own validation): returned unchanged. These don't
@@ -229,28 +251,25 @@ export function decodeNapiKernelError(err: unknown): Error {
229251
return err;
230252
}
231253

232-
const kErr = parsed as {
233-
code: string;
234-
message: string;
235-
sqlState?: string;
236-
errorCode?: string;
237-
vendorCode?: number;
238-
httpStatus?: number;
239-
retryable?: boolean;
240-
queryId?: string;
241-
};
254+
const envelope = parsed as Record<string, unknown>;
255+
const code = envelope.code as string;
256+
const msg = envelope.message as string;
257+
const sqlState = typeof envelope.sqlState === 'string' ? envelope.sqlState : undefined;
242258

243-
const jsErr = mapKernelErrorToJsError({
244-
code: kErr.code,
245-
message: kErr.message,
246-
sqlstate: kErr.sqlState,
247-
});
248-
attachMetadata(jsErr, {
249-
errorCode: kErr.errorCode,
250-
vendorCode: kErr.vendorCode,
251-
httpStatus: kErr.httpStatus,
252-
retryable: kErr.retryable,
253-
queryId: kErr.queryId,
254-
});
259+
const jsErr = mapKernelErrorToJsError({ code, message: msg, sqlstate: sqlState });
260+
261+
const meta = buildKernelMetadata(envelope);
262+
// Skip the namespace attachment entirely when no fields validated
263+
// through — keeps `err.kernelMetadata` absent rather than `{}` for
264+
// simple envelopes (the common case).
265+
if (
266+
meta.errorCode !== undefined ||
267+
meta.vendorCode !== undefined ||
268+
meta.httpStatus !== undefined ||
269+
meta.retryable !== undefined ||
270+
meta.queryId !== undefined
271+
) {
272+
defineErrorMetadata(jsErr, 'kernelMetadata', meta);
273+
}
255274
return jsErr;
256275
}

tests/integration/sea/auth-m2m-e2e.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { expect } from 'chai';
1616
import { DBSQLClient } from '../../../lib';
1717
import AuthenticationError from '../../../lib/errors/AuthenticationError';
18+
import { isBlankOrReserved } from '../../../lib/sea/SeaAuth';
1819

1920
/**
2021
* sea-auth M1 OAuth M2M end-to-end:
@@ -50,16 +51,15 @@ describe('sea-auth e2e — OAuth M2M through DBSQLClient ↔ SeaBackend ↔ napi
5051
this.timeout(120_000);
5152

5253
before(function gate() {
53-
// Reject not just absent env vars but also literal `'undefined'` /
54-
// `'null'` / whitespace-only values from buggy shell exports — these
54+
// Reject not just absent env vars but also blank/whitespace/literal-
55+
// `'undefined'`/`'null'` values from buggy shell exports — these
5556
// would otherwise reach the workspace as bogus creds and yield an
5657
// `invalid_client` indistinguishable from a real SP-not-registered
57-
// issue.
58-
const looksReal = (s: string | undefined): s is string => {
59-
if (typeof s !== 'string') return false;
60-
const t = s.trim();
61-
return t.length > 0 && t !== 'undefined' && t !== 'null';
62-
};
58+
// issue. Reuse the production `isBlankOrReserved` predicate so the
59+
// test gate stays in lockstep with the case-insensitive variant
60+
// shipped in round-2 (B-3 fix).
61+
const looksReal = (s: string | undefined): s is string =>
62+
typeof s === 'string' && !isBlankOrReserved(s);
6363
if (!looksReal(host) || !looksReal(path) || !looksReal(oauthClientId) || !looksReal(oauthClientSecret)) {
6464
// eslint-disable-next-line no-invalid-this
6565
this.skip();

0 commit comments

Comments
 (0)