Skip to content

Commit 7018cca

Browse files
committed
feat(cli): render upstream auth guidance
1 parent 05bd563 commit 7018cca

11 files changed

Lines changed: 821 additions & 121 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@cipherstash/stack': minor
3+
'stash': minor
4+
---
5+
6+
Surface CipherStash token-service refusals as typed diagnostics.
7+
8+
`@cipherstash/stack` operation and initialization failures now carry
9+
`authCode`, `help`, and `url` from stack-auth. The message remains stack-auth's
10+
original diagnostic message; Stack does not copy or rewrite its instructions.
11+
Callers can branch on `USAGE_LIMIT_EXCEEDED` or `ORG_NOT_PROVISIONED`, render
12+
`help`, and link to `url`.
13+
14+
`LockContext.identify()` also recognizes those two codes on a genuine CTS
15+
`402`, while declining malformed or unknown responses. Legacy valid JSON
16+
responses without `cs_code` retain the historical usage-limit classification.
17+
18+
`stash auth login` and `stash env` now consume `@cipherstash/auth` 0.44.0's
19+
typed failures. They print the upstream diagnostic guidance, preserve its URL,
20+
avoid suggesting another login for terminal account refusals, and expose
21+
terminal codes on the JSON stream. The JSON error envelope gains an optional
22+
`hint` for the upstream guidance.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
authFailureCliCode,
4+
authFailureHint,
5+
authFailureMessage,
6+
} from '../failure.js'
7+
8+
const LOGIN_HINT = 'Run `stash auth login` and try again.'
9+
10+
/**
11+
* An `AuthFailure` as `@cipherstash/auth` returns it.
12+
*
13+
* `type` is optional because the module's own `RenderableFailure` widens it to
14+
* `string | undefined` — so `undefined` and `''` are both shapes the lookups
15+
* have to survive, not hypotheticals the type system rules out.
16+
*/
17+
const failure = (type: string | undefined, message: string, help?: string) => ({
18+
...(type === undefined ? {} : { type }),
19+
error: new Error(message),
20+
...(help ? { help } : {}),
21+
})
22+
23+
describe('authFailureMessage', () => {
24+
it("appends stack-auth's help to the diagnosis", () => {
25+
// `miette` help is not part of an error's `Display`, so the remedy was
26+
// dropped at every call site: "Not authenticated" with no mention of how
27+
// to authenticate.
28+
expect(
29+
authFailureMessage(
30+
failure(
31+
'NOT_AUTHENTICATED',
32+
'Not authenticated',
33+
'Log in with `stash auth login`.',
34+
),
35+
),
36+
).toBe('Not authenticated. Log in with `stash auth login`.')
37+
})
38+
39+
it('leaves a failure without help exactly as it was', () => {
40+
expect(authFailureMessage(failure('INVALID_CLIENT', 'bad client'))).toBe(
41+
'bad client',
42+
)
43+
})
44+
45+
it('does not double a terminal full stop', () => {
46+
expect(
47+
authFailureMessage(failure('SERVER_ERROR', 'Boom.', 'Try later.')),
48+
).toBe('Boom. Try later.')
49+
})
50+
51+
// A CTS diagnosis is a sentence written by whoever raised it, and `.` is not
52+
// the only way one ends. `'Insufficient balance. Please upgrade your plan.'`
53+
// is the code path everyone tested; `'Upgrade now!'` is the one that came
54+
// back as `'Upgrade now!. See the dashboard.'`
55+
it.each([
56+
['a full stop', 'Boom.', 'Boom. Try later.'],
57+
['an exclamation mark', 'Upgrade now!', 'Upgrade now! Try later.'],
58+
[
59+
'a question mark',
60+
'Insufficient balance?',
61+
'Insufficient balance? Try later.',
62+
],
63+
['no punctuation at all', 'Boom', 'Boom. Try later.'],
64+
])('joins help to a diagnosis ending in %s', (_what, message, expected) => {
65+
expect(
66+
authFailureMessage(failure('SERVER_ERROR', message, 'Try later.')),
67+
).toBe(expected)
68+
})
69+
70+
it('is the help alone when the diagnosis is empty', () => {
71+
// Otherwise the separator is all that survives: '. Try later.'
72+
expect(authFailureMessage(failure('SERVER_ERROR', '', 'Try later.'))).toBe(
73+
'Try later.',
74+
)
75+
})
76+
})
77+
78+
describe('authFailureHint', () => {
79+
it('sends a usage-limit refusal to the dashboard instead of to login', () => {
80+
// The whole reason this function exists. `LOGIN_HINT` is the right advice
81+
// for a stale session and wrong for a billing refusal — a fresh login
82+
// cannot mint a credential CTS is withholding on billing grounds.
83+
const hint = authFailureHint(
84+
failure(
85+
'USAGE_LIMIT_EXCEEDED',
86+
'Insufficient balance.',
87+
'Upgrade at https://dashboard.cipherstash.com/billing.',
88+
),
89+
LOGIN_HINT,
90+
)
91+
92+
expect(hint).toContain('https://dashboard.cipherstash.com')
93+
expect(hint).not.toContain('auth login')
94+
})
95+
96+
it('sends an unprovisioned org to support, not to billing', () => {
97+
// A 402 has two causes and they need different remedies: an org over its
98+
// allowance upgrades, an org the usage system has never heard of has
99+
// nothing to buy.
100+
const hint = authFailureHint(
101+
failure(
102+
'ORG_NOT_PROVISIONED',
103+
'Not provisioned.',
104+
'Contact https://cipherstash.com/support.',
105+
),
106+
LOGIN_HINT,
107+
)
108+
109+
expect(hint).toContain('https://cipherstash.com/support')
110+
expect(hint).not.toContain('dashboard.cipherstash.com')
111+
})
112+
113+
it('keeps the caller-supplied hint for an ordinary auth failure', () => {
114+
expect(
115+
authFailureHint(failure('EXPIRED_TOKEN', 'Token expired'), LOGIN_HINT),
116+
).toBe(LOGIN_HINT)
117+
})
118+
119+
it('has no hint of its own when the caller supplies none', () => {
120+
expect(
121+
authFailureHint(failure('EXPIRED_TOKEN', 'Token expired')),
122+
).toBeUndefined()
123+
})
124+
125+
// `type` is `string | undefined` by design (see `RenderableFailure`), so all
126+
// three of these reach the lookup. An empty type is the one that bit: the
127+
// old `(failure.type && MAP.get(failure.type)) ?? fallback` short-circuited
128+
// to `''`, which is not nullish, so `??` never reached the fallback and
129+
// `stash env` built a `MintError` with `hint: ''` — suppressed by its own
130+
// `if (failure.hint)` guard, i.e. no hint at all.
131+
it.each([
132+
['an empty type', ''],
133+
['an absent type', undefined],
134+
['a type this CLI has never heard of', 'SOME_FUTURE_CODE'],
135+
])('falls back to the caller hint for %s', (_what, type) => {
136+
expect(authFailureHint(failure(type, 'boom'), LOGIN_HINT)).toBe(LOGIN_HINT)
137+
})
138+
})
139+
140+
describe('authFailureCliCode', () => {
141+
// The JSON stream's `code` is the only machine-readable field on it. An
142+
// agent that reads `session_invalid` runs `stash auth login` and comes
143+
// straight back here — which is the loop for BOTH terminal codes, not just
144+
// the billing one.
145+
it.each([
146+
['USAGE_LIMIT_EXCEEDED', 'usage_limit_exceeded'],
147+
['ORG_NOT_PROVISIONED', 'org_not_provisioned'],
148+
])('reports %s as its own terminal code', (type, expected) => {
149+
expect(
150+
authFailureCliCode(failure(type, 'refused'), 'session_invalid'),
151+
).toBe(expected)
152+
})
153+
154+
it.each([
155+
['an ordinary auth failure', 'EXPIRED_TOKEN'],
156+
['an empty type', ''],
157+
['an absent type', undefined],
158+
['a type this CLI has never heard of', 'SOME_FUTURE_CODE'],
159+
])('keeps the caller-supplied code for %s', (_what, type) => {
160+
expect(authFailureCliCode(failure(type, 'boom'), 'session_invalid')).toBe(
161+
'session_invalid',
162+
)
163+
})
164+
165+
it('has a CLI code for every code that gets a terminal hint', () => {
166+
// The two tables are what drifted: `ORG_NOT_PROVISIONED` had a hint saying
167+
// "logging in again will not clear this" while its code still said
168+
// `session_invalid`. Adding a terminal code has to land in both.
169+
for (const type of ['USAGE_LIMIT_EXCEEDED', 'ORG_NOT_PROVISIONED']) {
170+
expect(
171+
authFailureHint(
172+
failure(type, 'refused', 'Upstream remedy.'),
173+
LOGIN_HINT,
174+
),
175+
).toBe('Upstream remedy.')
176+
expect(
177+
authFailureCliCode(failure(type, 'refused'), 'session_invalid'),
178+
).not.toBe('session_invalid')
179+
}
180+
})
181+
})
182+
183+
describe('the pinned auth taxonomy', () => {
184+
it('maps a usage refusal to terminal guidance and a stable CLI code', () => {
185+
const refusal = failure(
186+
'USAGE_LIMIT_EXCEEDED',
187+
'Insufficient balance.',
188+
'Upgrade the plan.',
189+
)
190+
expect(authFailureHint(refusal, LOGIN_HINT)).not.toBe(LOGIN_HINT)
191+
expect(authFailureCliCode(refusal, 'session_invalid')).toBe(
192+
'usage_limit_exceeded',
193+
)
194+
})
195+
})

‎packages/cli/src/commands/auth/__tests__/login.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,111 @@ describe('login — interactive (non-json) failure handling', () => {
274274
expect(clack.log.error).toHaveBeenCalledWith('poll boom')
275275
})
276276
})
277+
278+
describe('login — a CTS usage-limit refusal', () => {
279+
/** The 402 CTS answers with when the organisation is over its allowance. */
280+
const usageLimit = () => ({
281+
failure: {
282+
type: 'USAGE_LIMIT_EXCEEDED',
283+
error: new Error('Insufficient balance. Please upgrade your plan.'),
284+
help: 'The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry.',
285+
url: 'https://dashboard.cipherstash.com/billing',
286+
},
287+
})
288+
289+
/** The other 402: the org isn't registered with the usage system at all. */
290+
const notProvisioned = () => ({
291+
failure: {
292+
type: 'ORG_NOT_PROVISIONED',
293+
error: new Error('Organization is not provisioned.'),
294+
help: 'The organisation is not registered with the usage system.',
295+
url: 'https://cipherstash.com/support',
296+
},
297+
})
298+
299+
it('points the user at the dashboard rather than at another login', async () => {
300+
// Logging in again cannot mint a credential CTS is withholding on billing
301+
// grounds, so the default "run `stash auth login`" hint would send the
302+
// user round a loop that has no exit.
303+
authMock.beginDeviceCodeFlow.mockResolvedValueOnce(usageLimit())
304+
spyExit()
305+
306+
await expect(
307+
login('us-east-1.aws', undefined, { json: false }),
308+
).rejects.toThrow('process.exit')
309+
310+
expect(clack.log.info).toHaveBeenCalledWith(
311+
expect.stringContaining('https://dashboard.cipherstash.com'),
312+
)
313+
})
314+
315+
it("carries stack-auth's remedy into the message", async () => {
316+
authMock.beginDeviceCodeFlow.mockResolvedValueOnce(usageLimit())
317+
spyExit()
318+
319+
await expect(
320+
login('us-east-1.aws', undefined, { json: false }),
321+
).rejects.toThrow('process.exit')
322+
323+
expect(clack.log.error).toHaveBeenCalledWith(
324+
expect.stringContaining('used its allowance'),
325+
)
326+
})
327+
328+
it('gives an agent the code to branch on', async () => {
329+
// The JSON stream carries `code` separately, so a consumer can stop
330+
// retrying without parsing English.
331+
authMock.beginDeviceCodeFlow.mockResolvedValueOnce(usageLimit())
332+
spyExit()
333+
const out = captureJsonLines()
334+
335+
await expect(
336+
login('us-east-1.aws', undefined, { json: true }),
337+
).rejects.toThrow('process.exit')
338+
339+
expect(out.lines()[0]).toMatchObject({
340+
status: 'error',
341+
code: 'USAGE_LIMIT_EXCEEDED',
342+
})
343+
})
344+
345+
// `--json` exists FOR agent consumers, and they are the ones who cannot see
346+
// the clack `log.info` line. Leaving the remedy off this stream puts the
347+
// dashboard URL exactly where nobody reading the stream can find it.
348+
it.each([
349+
['USAGE_LIMIT_EXCEEDED', usageLimit, 'https://dashboard.cipherstash.com'],
350+
['ORG_NOT_PROVISIONED', notProvisioned, 'https://cipherstash.com/support'],
351+
])('carries the %s remedy on the --json stream', async (code, mk, remedy) => {
352+
authMock.beginDeviceCodeFlow.mockResolvedValueOnce(mk())
353+
spyExit()
354+
const out = captureJsonLines()
355+
356+
await expect(
357+
login('us-east-1.aws', undefined, { json: true }),
358+
).rejects.toThrow('process.exit')
359+
360+
const event = out.lines()[0]
361+
expect(event).toMatchObject({ status: 'error', code })
362+
expect(event.hint).toEqual(expect.stringContaining(remedy))
363+
})
364+
365+
it('leaves the --json error envelope hint-free for an ordinary failure', async () => {
366+
// Additive means additive: an auth failure with no terminal remedy emits
367+
// the same three-key envelope it always did.
368+
authMock.beginDeviceCodeFlow.mockResolvedValueOnce(
369+
failure('EXPIRED_TOKEN', 'Token expired'),
370+
)
371+
spyExit()
372+
const out = captureJsonLines()
373+
374+
await expect(
375+
login('us-east-1.aws', undefined, { json: true }),
376+
).rejects.toThrow('process.exit')
377+
378+
expect(Object.keys(out.lines()[0] as object).sort()).toEqual([
379+
'code',
380+
'message',
381+
'status',
382+
])
383+
})
384+
})

‎packages/cli/src/commands/auth/events.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,24 @@ export function emitJsonEvent(event: Record<string, unknown>): void {
1414
}
1515

1616
/**
17-
* Emit the shared `{ status: 'error', code, message }` envelope. The single
18-
* source of truth for how a failure surfaces on the NDJSON stream.
17+
* Emit the shared `{ status: 'error', code, message }` envelope, plus `hint`
18+
* when the failure carries one. The single source of truth for how a failure
19+
* surfaces on the NDJSON stream.
20+
*
21+
* `hint` is the same remedy the interactive path prints as a follow-up line —
22+
* "upgrade the plan at dashboard.cipherstash.com", "contact support" — and it
23+
* belongs here because `--json` exists FOR consumers that never see the clack
24+
* output. Omitting the key entirely when there is no hint keeps the envelope
25+
* byte-identical for every failure that had none, so this is additive: an
26+
* existing parser sees `status`/`code`/`message` exactly as before.
27+
*
28+
* Any `{cli}` placeholder must be resolved by the caller — an unsubstituted
29+
* token is not machine-readable guidance.
1930
*/
20-
export function emitJsonError(code: string, message: string): void {
21-
emitJsonEvent({ status: 'error', code, message })
31+
export function emitJsonError(
32+
code: string,
33+
message: string,
34+
hint?: string,
35+
): void {
36+
emitJsonEvent({ status: 'error', code, message, ...(hint ? { hint } : {}) })
2237
}

0 commit comments

Comments
 (0)