Skip to content

Commit 98d5ecf

Browse files
committed
sea-auth-u2m: round-2 fixup — wrap close() in decodeNapiKernelError, raise on U2M+id, case-insensitive validation, preserve all error envelope fields
Addresses devils-advocate-auth-u2m-1 round-1 findings on commit 8e99b40. NF-1 is a HIGH continuation of DA-F1 — the previous fixup wired `decodeNapiKernelError` at `SeaBackend.openSession` but missed the second extant napi call site, `SeaSessionBackend.close()`. NF-2 through NF-4 are mediums. ## NF-1 (HIGH) — close() wrapped `SeaSessionBackend.close()` calls `await this.connection.close()` on the napi `Connection`. Kernel errors from there (e.g., a delete- session RPC failure that the kernel chose to surface despite the fire-and-forget pattern) were reaching JS callers as raw `Error` with `__databricks_error__:` envelope. Wrapped in try/catch + `throw decodeNapiKernelError(err)` — same 3-line shape as openSession. Per `grep -rn "await this\.native\.\|await this\.connection\." lib/sea/`, these are the only two napi call sites on `sea-auth-u2m`. Future napi call sites on sea-execution / sea-results / sea-operation branches need the same wrap (Phase 2; tracked elsewhere). ## NF-2 (medium) — raise on U2M + oauthClientId Previously, `oauthClientId` set without `oauthClientSecret` was silently dropped (kernel uses built-in `databricks-cli`). A user setting the field clearly expects it honored; silent-drop hid intent. Flipped to throw HiveDriverError with explicit guidance ("kernel uses 'databricks-cli'; omit for U2M or supply oauthClientSecret for M2M"). The matching unit test in `tests/unit/sea/auth-u2m.test.ts` flipped from "drops the supplied oauthClientId" to "rejects oauthClientId on the U2M path with a clear 'not supported' error". ## NF-3 (medium) — case-insensitive reserved-literal validation `isBlankOrReserved` previously compared `trimmed === 'undefined'` and `=== 'null'`, so `'UNDEFINED'`, `'Null'`, `'NULL'`, `'nUlL'`, etc. slipped through. Changed to `trimmed.toLowerCase()` before the comparison. New unit case in `auth-edge-cases.test.ts` iterates five case variants and asserts each rejects. ## NF-4 (medium) — preserve all 7 kernel envelope fields `decodeNapiKernelError` previously routed only `{code, message, sqlState}` to the JS error class. The kernel envelope at `native/sea/src/error.rs:50-89` actually carries 7 fields total (`code`, `message`, `sqlState`, `errorCode`, `vendorCode`, `httpStatus`, `retryable`, `queryId`). The remaining 5 were silently dropped. Thrift parity demand: thrift errors carry these fields. Added `attachMetadata(error, meta)` helper that `Object.defineProperty`s each non-undefined field as a non-enumerable own-property — matches the way `attachSqlState` works and the way Node attaches `.code` to system errors. Two new unit tests verify (a) all 5 fields round-trip through a synthetic envelope, (b) they remain non-enumerable (absent from `Object.keys(err)`) but accessible via direct property read. ## Items DEFERRED to Phase 2 (recorded here for the curator) - NF-5 (envelope versioning): `__databricks_error__:` payload has no `version` field. A kernel refactor that renames a field would silently break the JS decoder. Phase 2: add `version: 1` to the kernel-side serialization, check + fallback on JS side. Not in this commit because it requires coordinated kernel-side change. - NF-6 (U2M e2e harness): `auth-u2m-e2e.test.ts` is fully `it.skip` pending the Playwright/Puppeteer harness. Devils- advocate noted that port-collision + headless-negative-path subsets don't strictly need a browser. Phase 2: enable those subsets when the harness lands. Not in this commit because the work is mostly harness-wiring rather than test code. Tests: - Unit: 79/79 pass (was 74 before this commit: +5 — 1 case-insensitive reserved literal sweep, 1 M2M oauthClientSecret reserved-literal reject, 2 envelope-metadata preservation, 1 close() decode + 1 flipped from drop-to-reject which kept the count net same — but the OAuthClientId test rewrite is on a different file). - TypeScript build: clean. - Native build: unchanged (no Rust changes this commit). Co-authored-by: Isaac
1 parent 8e99b40 commit 98d5ecf

5 files changed

Lines changed: 204 additions & 23 deletions

File tree

lib/sea/SeaAuth.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,14 @@ function prependSlash(str: string): string {
8080
/**
8181
* Reject inputs that pass `typeof === 'string' && length > 0` but are
8282
* structurally useless as credentials: whitespace-only strings, and the
83-
* literal strings `'undefined'` / `'null'` that buggy shell exports
84-
* (e.g. `export FOO="$UNSET_VAR"`) produce. Surfacing these here means
85-
* an OAuth flow's `invalid_client` from the workspace is always a real
86-
* credential mismatch, never a malformed-input passthrough.
83+
* literal strings `'undefined'` / `'null'` (case-insensitive) that buggy
84+
* shell exports (e.g. `export FOO="$UNSET_VAR"`) produce. Surfacing
85+
* these here means an OAuth flow's `invalid_client` from the workspace
86+
* is always a real credential mismatch, never a malformed-input passthrough.
8787
*/
8888
function isBlankOrReserved(s: string): boolean {
89-
const trimmed = s.trim();
90-
return trimmed.length === 0 || trimmed === 'undefined' || trimmed === 'null';
89+
const normalized = s.trim().toLowerCase();
90+
return normalized.length === 0 || normalized === 'undefined' || normalized === 'null';
9191
}
9292

9393
/**
@@ -182,6 +182,16 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
182182
// (`DBSQLClient.ts:143`): `oauthClientSecret defined ? M2M : U2M`.
183183
if (oauth.oauthClientSecret === undefined) {
184184
// U2M.
185+
if (oauth.oauthClientId !== undefined) {
186+
// The kernel hardcodes `client_id = "databricks-cli"` for U2M;
187+
// there's no JS-side override knob. Silently dropping a
188+
// user-supplied id would hide that the kernel ignored it.
189+
throw new HiveDriverError(
190+
'SEA backend: `oauthClientId` is not supported on the OAuth U2M flow; ' +
191+
"the kernel uses the built-in 'databricks-cli' client. " +
192+
'Omit `oauthClientId` for U2M, or supply `oauthClientSecret` for the M2M flow.',
193+
);
194+
}
185195
if (oauth.persistence !== undefined) {
186196
throw new HiveDriverError(
187197
'SEA backend: `persistence` (custom OAuth token store) is not yet wired through ' +

lib/sea/SeaBackend.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ export class SeaSessionBackend implements ISessionBackend {
122122
/* eslint-enable @typescript-eslint/no-unused-vars */
123123

124124
public async close(): Promise<Status> {
125-
await this.connection.close();
125+
try {
126+
await this.connection.close();
127+
} catch (err) {
128+
throw decodeNapiKernelError(err);
129+
}
126130
return Status.success();
127131
}
128132
}

lib/sea/SeaErrorMapping.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,48 @@ export function mapKernelErrorToJsError(kErr: KernelErrorShape): ErrorWithSqlSta
149149
return attachSqlState(error, sqlstate);
150150
}
151151

152+
/**
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.
159+
*/
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+
}
179+
}
180+
}
181+
152182
/**
153183
* Decode a napi-binding error into the typed JS error class.
154184
*
155185
* Two paths:
156186
* - Structured kernel error: `Error.message` starts with
157187
* {@link ERROR_SENTINEL} followed by a JSON envelope. We strip the
158-
* sentinel, parse the JSON, and route through
159-
* {@link mapKernelErrorToJsError}.
188+
* 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.
160194
* - Binding-side error (e.g. `napi::Error::new(InvalidArg, "openSession:
161195
* \`token\` is required for the requested auth mode")` produced by
162196
* the binding's own validation): returned unchanged. These don't
@@ -195,10 +229,28 @@ export function decodeNapiKernelError(err: unknown): Error {
195229
return err;
196230
}
197231

198-
const kErr = parsed as { code: string; message: string; sqlState?: string };
199-
return mapKernelErrorToJsError({
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+
};
242+
243+
const jsErr = mapKernelErrorToJsError({
200244
code: kErr.code,
201245
message: kErr.message,
202246
sqlstate: kErr.sqlState,
203247
});
248+
attachMetadata(jsErr, {
249+
errorCode: kErr.errorCode,
250+
vendorCode: kErr.vendorCode,
251+
httpStatus: kErr.httpStatus,
252+
retryable: kErr.retryable,
253+
queryId: kErr.queryId,
254+
});
255+
return jsErr;
204256
}

tests/unit/sea/auth-edge-cases.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,36 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
6161
);
6262
});
6363

64+
it('rejects mixed-case "UNDEFINED" / "Null" / "NULL" as PAT (case-insensitive)', () => {
65+
for (const reserved of ['UNDEFINED', 'Undefined', 'Null', 'NULL', 'nUlL']) {
66+
const opts: ConnectionOptions = {
67+
host: 'example.cloud.databricks.com',
68+
path: '/sql/1.0/warehouses/abc',
69+
token: reserved,
70+
};
71+
72+
expect(() => buildSeaConnectionOptions(opts), `for token=${reserved}`).to.throw(
73+
AuthenticationError,
74+
/non-empty PAT/,
75+
);
76+
}
77+
});
78+
79+
it('rejects mixed-case reserved literals on oauthClientSecret too', () => {
80+
const opts: ConnectionOptions = {
81+
host: 'example.cloud.databricks.com',
82+
path: '/sql/1.0/warehouses/abc',
83+
authType: 'databricks-oauth',
84+
oauthClientId: 'client-uuid',
85+
oauthClientSecret: 'NULL',
86+
};
87+
88+
expect(() => buildSeaConnectionOptions(opts)).to.throw(
89+
AuthenticationError,
90+
/oauthClientSecret.*non-empty non-whitespace/,
91+
);
92+
});
93+
6494
it('rejects whitespace-only oauthClientId on M2M', () => {
6595
const opts: ConnectionOptions = {
6696
host: 'example.cloud.databricks.com',
@@ -339,4 +369,91 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => {
339369
expect(caught).to.be.instanceOf(Error);
340370
expect((caught as Error).message).to.contain('not valid json');
341371
});
372+
373+
// NF-4: preserve all 7 kernel envelope fields on the decoded JS error
374+
// as non-enumerable own-properties so callers can read them without
375+
// polluting JSON.stringify(error).
376+
it('preserves errorCode + vendorCode + httpStatus + retryable + queryId on the decoded error', async () => {
377+
const binding = bindingRejectingWith(
378+
'{"code":"Unavailable","message":"upstream timed out",' +
379+
'"sqlState":"08006","errorCode":"UPSTREAM_TIMEOUT","vendorCode":1234,' +
380+
'"httpStatus":503,"retryable":true,"queryId":"query-abc-123"}',
381+
);
382+
const backend = new SeaBackend(binding);
383+
await backend.connect(validConnectArgs);
384+
385+
let caught: unknown;
386+
try {
387+
await backend.openSession({});
388+
} catch (e) {
389+
caught = e;
390+
}
391+
const err = caught as {
392+
sqlState?: string;
393+
errorCode?: string;
394+
vendorCode?: number;
395+
httpStatus?: number;
396+
retryable?: boolean;
397+
queryId?: string;
398+
};
399+
expect(err.sqlState).to.equal('08006');
400+
expect(err.errorCode).to.equal('UPSTREAM_TIMEOUT');
401+
expect(err.vendorCode).to.equal(1234);
402+
expect(err.httpStatus).to.equal(503);
403+
expect(err.retryable).to.equal(true);
404+
expect(err.queryId).to.equal('query-abc-123');
405+
});
406+
407+
it('keeps the metadata properties non-enumerable (matches sqlState pattern)', async () => {
408+
const binding = bindingRejectingWith(
409+
'{"code":"NetworkError","message":"x","httpStatus":502}',
410+
);
411+
const backend = new SeaBackend(binding);
412+
await backend.connect(validConnectArgs);
413+
414+
let caught: unknown;
415+
try {
416+
await backend.openSession({});
417+
} catch (e) {
418+
caught = e;
419+
}
420+
expect(Object.keys(caught as object)).to.not.include('httpStatus');
421+
// But direct access still works.
422+
expect((caught as { httpStatus?: number }).httpStatus).to.equal(502);
423+
});
424+
425+
// NF-1: SeaSessionBackend.close() must wrap the napi call too.
426+
it('SeaSessionBackend.close() decodes kernel-error envelopes from native.close()', async () => {
427+
const { binding } = makeFakeBinding();
428+
// Make openSession return a fake Connection whose close() throws
429+
// a kernel-shaped envelope.
430+
const failingClose = {
431+
async executeStatement() {
432+
throw new Error('unused');
433+
},
434+
async close() {
435+
throw new Error(
436+
'__databricks_error__:{"code":"Internal","message":"server-side close failed"}',
437+
);
438+
},
439+
};
440+
binding.openSession = (async () => failingClose as unknown) as typeof binding.openSession;
441+
442+
const backend = new SeaBackend(binding);
443+
await backend.connect(validConnectArgs);
444+
const session = await backend.openSession({});
445+
446+
let caught: unknown;
447+
try {
448+
await session.close();
449+
} catch (e) {
450+
caught = e;
451+
}
452+
// Before the NF-1 fix, this would surface as a raw Error whose
453+
// message starts with `__databricks_error__:`. After the fix, the
454+
// sentinel is stripped and the typed class is dispatched.
455+
expect(caught).to.be.instanceOf(HiveDriverError);
456+
expect((caught as Error).message).to.equal('server-side close failed');
457+
expect((caught as Error).message).to.not.contain('__databricks_error__');
458+
});
342459
});

tests/unit/sea/auth-u2m.test.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,25 +37,23 @@ describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => {
3737
});
3838
});
3939

40-
it('drops the supplied oauthClientId on the U2M path (kernel uses its own default)', () => {
41-
// The thrift parity story: thrift's getClientId() falls back to
42-
// `databricks-cli` when undefined. Here we tell the kernel to do
43-
// the same via `client_id: None`. If a user supplies a clientId
44-
// alongside no secret, we treat that as U2M and use kernel default
45-
// — explicitly NOT propagating the supplied id, because the kernel
46-
// surface for U2M client_id is None-or-Some-with-no-default-rewrite,
47-
// and exposing the override here is out-of-scope-for-this-task.
40+
it('rejects oauthClientId on the U2M path with a clear "not supported" error', () => {
41+
// The kernel hardcodes `client_id = "databricks-cli"` for U2M; there's
42+
// no JS-side override knob. Silently dropping a user-supplied id would
43+
// hide that the kernel ignored it, so we surface the limitation
44+
// explicitly. Earlier revisions of this code silently dropped — flipped
45+
// to raise based on devils-advocate-auth-u2m-1 round-1 (NF-2).
4846
const opts: ConnectionOptions = {
4947
host: 'example.cloud.databricks.com',
5048
path: '/sql/1.0/warehouses/abc',
5149
authType: 'databricks-oauth',
5250
oauthClientId: 'custom-client',
5351
};
5452

55-
const native = buildSeaConnectionOptions(opts);
56-
expect(native.authMode).to.equal('OAuthU2m');
57-
// Custom clientId is intentionally not forwarded — see comment above.
58-
expect(native).to.not.have.property('oauthClientId');
53+
expect(() => buildSeaConnectionOptions(opts)).to.throw(
54+
HiveDriverError,
55+
/oauthClientId.*not supported on the OAuth U2M flow/,
56+
);
5957
});
6058

6159
it('prepends `/` to the path on the U2M branch too', () => {

0 commit comments

Comments
 (0)