@formigio/fazemos-cli 0.10.77 → 0.10.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * OIDC redirect login for the CLI — authorization code + PKCE against the
3
+ * Cognito hosted UI, with the code caught on a loopback listener.
4
+ *
5
+ * ## Why this replaced email/password
6
+ * `USER_PASSWORD_AUTH` put the operator's password on the CLI's argv (visible
7
+ * in `ps` and in shell history), could never satisfy an MFA or federated-IdP
8
+ * challenge, and hardcoded the assumption that Cognito is the only identity
9
+ * source. The redirect flow moves all of that into the browser, where the
10
+ * hosted UI already handles it. The CLI's app client has
11
+ * ALLOW_USER_PASSWORD_AUTH switched off, so this is not merely the preferred
12
+ * path — it is the only one that works.
13
+ *
14
+ * ## Why the ports are a fixed list
15
+ * RFC 8252 §7.3 says a native app should bind an ephemeral loopback port and
16
+ * ignore the port when matching. Cognito does not implement that: it compares
17
+ * redirect_uri byte-for-byte against the registered CallbackURLs and supports
18
+ * no wildcard. So the ports have to be enumerated on both sides.
19
+ * LOOPBACK_PORTS below MUST stay identical to CallbackURLs on
20
+ * CliUserPoolClient in fazemos-api/template.yaml.
21
+ *
22
+ * ## What protects the code in transit
23
+ * PKCE (S256). The verifier never leaves this process, and the authorization
24
+ * code is worthless without it — which is what makes a plaintext loopback
25
+ * redirect safe, and why Cognito permits http:// only for localhost. `state`
26
+ * is checked on the way back to reject a callback this process did not start.
27
+ */
28
+ /**
29
+ * Loopback ports the CLI will try, in order.
30
+ *
31
+ * MUST match CallbackURLs on CliUserPoolClient in fazemos-api/template.yaml.
32
+ * Changing this list without redeploying that stack produces a Cognito error
33
+ * page reading "redirect_mismatch" with no further explanation.
34
+ */
35
+ export declare const LOOPBACK_PORTS: number[];
36
+ /** Path component of the redirect URI. Also fixed by the registered clients. */
37
+ export declare const CALLBACK_PATH = "/callback";
38
+ /** Scopes requested. Must be a subset of AllowedOAuthScopes on the client. */
39
+ export declare const SCOPES: string[];
40
+ export interface OidcEndpoints {
41
+ /** Hosted-UI origin, no trailing slash. */
42
+ hostedUiDomain: string;
43
+ clientId: string;
44
+ }
45
+ export interface TokenSet {
46
+ idToken: string;
47
+ refreshToken: string;
48
+ /** Seconds until the ID/access tokens expire. */
49
+ expiresIn: number;
50
+ }
51
+ export declare function createPkcePair(): {
52
+ verifier: string;
53
+ challenge: string;
54
+ };
55
+ export declare function createState(): string;
56
+ export declare function buildAuthorizeUrl(opts: {
57
+ endpoints: OidcEndpoints;
58
+ redirectUri: string;
59
+ state: string;
60
+ codeChallenge: string;
61
+ }): string;
62
+ export declare function buildLogoutUrl(endpoints: OidcEndpoints, redirectUri: string): string;
63
+ export interface CallbackResult {
64
+ code: string;
65
+ redirectUri: string;
66
+ }
67
+ /**
68
+ * Bind the first free loopback port and resolve once the browser hits
69
+ * CALLBACK_PATH with a matching `state`.
70
+ *
71
+ * The server is handed to `onReady` along with the redirect URI it bound, so
72
+ * the caller can build the authorize URL with the port that was actually
73
+ * acquired — the URL cannot be constructed before the bind succeeds.
74
+ */
75
+ export declare function waitForCallback(opts: {
76
+ state: string;
77
+ timeoutMs: number;
78
+ onReady: (redirectUri: string) => void | Promise<void>;
79
+ }): Promise<CallbackResult>;
80
+ /**
81
+ * Trade the authorization code for tokens at the hosted UI's token endpoint.
82
+ *
83
+ * No client secret is sent because the CLI client is public
84
+ * (GenerateSecret: false) — PKCE is what authenticates the exchange.
85
+ */
86
+ export declare function exchangeCodeForTokens(opts: {
87
+ endpoints: OidcEndpoints;
88
+ code: string;
89
+ redirectUri: string;
90
+ codeVerifier: string;
91
+ }): Promise<TokenSet>;
92
+ /**
93
+ * Read the `email` claim out of an ID token, for display and for the stored
94
+ * session label.
95
+ *
96
+ * This does NOT verify the signature, and must never be used for an
97
+ * authorization decision. It is safe here because the token came directly
98
+ * from Cognito's token endpoint over TLS moments earlier, and because the API
99
+ * verifies it properly on every call.
100
+ */
101
+ export declare function readEmailClaim(idToken: string): string;
102
+ /**
103
+ * Open a URL in the user's default browser.
104
+ *
105
+ * Returns false when no opener is available (a bare Linux container, a
106
+ * headless CI box) so the caller can fall back to printing the URL instead of
107
+ * pretending it worked. stdio is ignored and the child unref'd so a browser
108
+ * that logs to stdout cannot corrupt the CLI's own output or hold the process
109
+ * open after login completes.
110
+ */
111
+ export declare function openBrowser(url: string): boolean;
package/dist/oidc.js ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * OIDC redirect login for the CLI — authorization code + PKCE against the
3
+ * Cognito hosted UI, with the code caught on a loopback listener.
4
+ *
5
+ * ## Why this replaced email/password
6
+ * `USER_PASSWORD_AUTH` put the operator's password on the CLI's argv (visible
7
+ * in `ps` and in shell history), could never satisfy an MFA or federated-IdP
8
+ * challenge, and hardcoded the assumption that Cognito is the only identity
9
+ * source. The redirect flow moves all of that into the browser, where the
10
+ * hosted UI already handles it. The CLI's app client has
11
+ * ALLOW_USER_PASSWORD_AUTH switched off, so this is not merely the preferred
12
+ * path — it is the only one that works.
13
+ *
14
+ * ## Why the ports are a fixed list
15
+ * RFC 8252 §7.3 says a native app should bind an ephemeral loopback port and
16
+ * ignore the port when matching. Cognito does not implement that: it compares
17
+ * redirect_uri byte-for-byte against the registered CallbackURLs and supports
18
+ * no wildcard. So the ports have to be enumerated on both sides.
19
+ * LOOPBACK_PORTS below MUST stay identical to CallbackURLs on
20
+ * CliUserPoolClient in fazemos-api/template.yaml.
21
+ *
22
+ * ## What protects the code in transit
23
+ * PKCE (S256). The verifier never leaves this process, and the authorization
24
+ * code is worthless without it — which is what makes a plaintext loopback
25
+ * redirect safe, and why Cognito permits http:// only for localhost. `state`
26
+ * is checked on the way back to reject a callback this process did not start.
27
+ */
28
+ import { createServer } from 'http';
29
+ import { createHash, randomBytes } from 'crypto';
30
+ import { spawn } from 'child_process';
31
+ /**
32
+ * Loopback ports the CLI will try, in order.
33
+ *
34
+ * MUST match CallbackURLs on CliUserPoolClient in fazemos-api/template.yaml.
35
+ * Changing this list without redeploying that stack produces a Cognito error
36
+ * page reading "redirect_mismatch" with no further explanation.
37
+ */
38
+ export const LOOPBACK_PORTS = [8976, 8977, 8978, 8979, 8980];
39
+ /** Path component of the redirect URI. Also fixed by the registered clients. */
40
+ export const CALLBACK_PATH = '/callback';
41
+ /** Scopes requested. Must be a subset of AllowedOAuthScopes on the client. */
42
+ export const SCOPES = ['openid', 'email', 'profile'];
43
+ // ── PKCE ────────────────────────────────────────────────────
44
+ /**
45
+ * base64url per RFC 7636 §A — standard base64 with +/ swapped for -_ and the
46
+ * padding stripped. Node's 'base64url' encoding does exactly this, but is
47
+ * spelled out here because a stray '=' silently breaks the S256 comparison
48
+ * on Cognito's side with a generic invalid_grant.
49
+ */
50
+ function base64url(buf) {
51
+ return buf.toString('base64url');
52
+ }
53
+ export function createPkcePair() {
54
+ // 32 random bytes → 43 base64url chars, comfortably inside RFC 7636's
55
+ // 43–128 character range for code_verifier.
56
+ const verifier = base64url(randomBytes(32));
57
+ const challenge = base64url(createHash('sha256').update(verifier).digest());
58
+ return { verifier, challenge };
59
+ }
60
+ export function createState() {
61
+ return base64url(randomBytes(16));
62
+ }
63
+ // ── Authorize URL ───────────────────────────────────────────
64
+ export function buildAuthorizeUrl(opts) {
65
+ const params = new URLSearchParams({
66
+ response_type: 'code',
67
+ client_id: opts.endpoints.clientId,
68
+ redirect_uri: opts.redirectUri,
69
+ scope: SCOPES.join(' '),
70
+ state: opts.state,
71
+ code_challenge: opts.codeChallenge,
72
+ code_challenge_method: 'S256',
73
+ });
74
+ return `${opts.endpoints.hostedUiDomain.replace(/\/$/, '')}/oauth2/authorize?${params}`;
75
+ }
76
+ export function buildLogoutUrl(endpoints, redirectUri) {
77
+ const params = new URLSearchParams({
78
+ client_id: endpoints.clientId,
79
+ logout_uri: redirectUri,
80
+ });
81
+ return `${endpoints.hostedUiDomain.replace(/\/$/, '')}/logout?${params}`;
82
+ }
83
+ // ── Loopback listener ───────────────────────────────────────
84
+ /** A browser-facing page. Deliberately dependency-free and tiny. */
85
+ function resultPage(title, detail) {
86
+ return `<!doctype html><meta charset="utf-8"><title>${title}</title>
87
+ <style>body{font:16px/1.6 system-ui,-apple-system,sans-serif;margin:16vh auto;max-width:34rem;padding:0 1.5rem;color:#1a1a1a}
88
+ h1{font-size:1.35rem;margin:0 0 .5rem}p{margin:0;color:#555}
89
+ @media(prefers-color-scheme:dark){body{background:#111;color:#eee}p{color:#aaa}}</style>
90
+ <h1>${title}</h1><p>${detail}</p>`;
91
+ }
92
+ /**
93
+ * Bind the first free loopback port and resolve once the browser hits
94
+ * CALLBACK_PATH with a matching `state`.
95
+ *
96
+ * The server is handed to `onReady` along with the redirect URI it bound, so
97
+ * the caller can build the authorize URL with the port that was actually
98
+ * acquired — the URL cannot be constructed before the bind succeeds.
99
+ */
100
+ export function waitForCallback(opts) {
101
+ return new Promise((resolve, reject) => {
102
+ let server;
103
+ let settled = false;
104
+ let timer;
105
+ const finish = (err, result) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ if (timer)
110
+ clearTimeout(timer);
111
+ // close() only stops NEW connections; a keep-alive socket keeps the port
112
+ // bound indefinitely. Every response below sets `Connection: close` and
113
+ // finish() runs from the res.end() callback, so the body is already
114
+ // flushed by the time we tear the sockets down — without this a second
115
+ // login in the same process hits EADDRINUSE on every registered port.
116
+ server?.close();
117
+ server?.closeAllConnections?.();
118
+ if (err)
119
+ reject(err);
120
+ else
121
+ resolve(result);
122
+ };
123
+ /** Reply, wait for the bytes to leave, then settle. */
124
+ const respond = (res, status, body, contentType, done) => {
125
+ res.writeHead(status, { 'Content-Type': contentType, Connection: 'close' });
126
+ res.end(body, done);
127
+ };
128
+ const tryPort = (index) => {
129
+ if (index >= LOOPBACK_PORTS.length) {
130
+ finish(new Error(`Could not bind any of the loopback ports ${LOOPBACK_PORTS.join(', ')}. ` +
131
+ `Close whatever is using them and try again — these ports are fixed by ` +
132
+ `the redirect URIs registered with Cognito and cannot be chosen freely.`));
133
+ return;
134
+ }
135
+ const port = LOOPBACK_PORTS[index];
136
+ const s = createServer((req, res) => {
137
+ // Only the callback path is served. Anything else (a favicon request,
138
+ // a stray probe) gets a 404 and is ignored, so it cannot settle the
139
+ // promise or be mistaken for the real redirect.
140
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
141
+ if (url.pathname !== CALLBACK_PATH) {
142
+ // No `Connection: close` and no finish() — a stray request must not
143
+ // end the flow, and the listener stays up for the real redirect.
144
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
145
+ res.end('Not found');
146
+ return;
147
+ }
148
+ const error = url.searchParams.get('error');
149
+ const code = url.searchParams.get('code');
150
+ const returnedState = url.searchParams.get('state');
151
+ const HTML = 'text/html; charset=utf-8';
152
+ if (error) {
153
+ const description = url.searchParams.get('error_description') || '';
154
+ respond(res, 400, resultPage('Sign-in failed', `${error}. You can close this tab.`), HTML, () => finish(new Error(`Authorization failed: ${error}${description ? ` — ${description}` : ''}`)));
155
+ return;
156
+ }
157
+ // A mismatched state means this callback belongs to some other flow.
158
+ // Reject it rather than exchanging a code we did not ask for.
159
+ if (returnedState !== opts.state) {
160
+ respond(res, 400, resultPage('Sign-in failed', 'State mismatch. You can close this tab.'), HTML, () => finish(new Error('Authorization failed: state mismatch (the callback did not come from this login attempt)')));
161
+ return;
162
+ }
163
+ if (!code) {
164
+ respond(res, 400, resultPage('Sign-in failed', 'No authorization code was returned. You can close this tab.'), HTML, () => finish(new Error('Authorization failed: no code in the callback')));
165
+ return;
166
+ }
167
+ respond(res, 200, resultPage('Signed in to Fazemos', 'You can close this tab and return to your terminal.'), HTML, () => finish(null, { code, redirectUri: `http://localhost:${port}${CALLBACK_PATH}` }));
168
+ });
169
+ s.on('error', (err) => {
170
+ // Port taken (or refused): fall through to the next candidate rather
171
+ // than failing the whole login.
172
+ if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
173
+ s.close();
174
+ tryPort(index + 1);
175
+ return;
176
+ }
177
+ finish(err);
178
+ });
179
+ // 127.0.0.1, not 0.0.0.0 — the listener must not be reachable from the
180
+ // network. Binding the wildcard would expose the authorization code to
181
+ // anything that can reach this host.
182
+ s.listen(port, '127.0.0.1', () => {
183
+ server = s;
184
+ const addr = s.address();
185
+ const redirectUri = `http://localhost:${addr.port}${CALLBACK_PATH}`;
186
+ timer = setTimeout(() => finish(new Error('Timed out waiting for the browser redirect. Run the command again.')), opts.timeoutMs);
187
+ Promise.resolve(opts.onReady(redirectUri)).catch((err) => finish(err));
188
+ });
189
+ };
190
+ tryPort(0);
191
+ });
192
+ }
193
+ // ── Token exchange ──────────────────────────────────────────
194
+ /**
195
+ * Trade the authorization code for tokens at the hosted UI's token endpoint.
196
+ *
197
+ * No client secret is sent because the CLI client is public
198
+ * (GenerateSecret: false) — PKCE is what authenticates the exchange.
199
+ */
200
+ export async function exchangeCodeForTokens(opts) {
201
+ const body = new URLSearchParams({
202
+ grant_type: 'authorization_code',
203
+ client_id: opts.endpoints.clientId,
204
+ code: opts.code,
205
+ redirect_uri: opts.redirectUri,
206
+ code_verifier: opts.codeVerifier,
207
+ });
208
+ const res = await fetch(`${opts.endpoints.hostedUiDomain.replace(/\/$/, '')}/oauth2/token`, {
209
+ method: 'POST',
210
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
211
+ body: body.toString(),
212
+ });
213
+ const text = await res.text();
214
+ if (!res.ok) {
215
+ // Cognito returns {"error":"invalid_grant"} with no detail on the common
216
+ // failures, so name the likely causes rather than echoing one word.
217
+ let detail = text;
218
+ try {
219
+ const parsed = JSON.parse(text);
220
+ detail = parsed.error_description || parsed.error || text;
221
+ }
222
+ catch { /* keep the raw body */ }
223
+ throw new Error(`Token exchange failed (${res.status}): ${detail}. ` +
224
+ `Usual causes: the code was already used, it expired (they last a few minutes), ` +
225
+ `or redirect_uri did not match the one sent to /oauth2/authorize.`);
226
+ }
227
+ const data = JSON.parse(text);
228
+ if (!data.id_token || !data.refresh_token) {
229
+ throw new Error('Token exchange succeeded but returned no id_token/refresh_token.');
230
+ }
231
+ return {
232
+ idToken: data.id_token,
233
+ refreshToken: data.refresh_token,
234
+ expiresIn: data.expires_in ?? 3600,
235
+ };
236
+ }
237
+ // ── ID token claims ─────────────────────────────────────────
238
+ /**
239
+ * Read the `email` claim out of an ID token, for display and for the stored
240
+ * session label.
241
+ *
242
+ * This does NOT verify the signature, and must never be used for an
243
+ * authorization decision. It is safe here because the token came directly
244
+ * from Cognito's token endpoint over TLS moments earlier, and because the API
245
+ * verifies it properly on every call.
246
+ */
247
+ export function readEmailClaim(idToken) {
248
+ try {
249
+ const payload = idToken.split('.')[1];
250
+ if (!payload)
251
+ return 'unknown';
252
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
253
+ return claims.email || claims['cognito:username'] || 'unknown';
254
+ }
255
+ catch {
256
+ return 'unknown';
257
+ }
258
+ }
259
+ // ── Browser ─────────────────────────────────────────────────
260
+ /**
261
+ * Open a URL in the user's default browser.
262
+ *
263
+ * Returns false when no opener is available (a bare Linux container, a
264
+ * headless CI box) so the caller can fall back to printing the URL instead of
265
+ * pretending it worked. stdio is ignored and the child unref'd so a browser
266
+ * that logs to stdout cannot corrupt the CLI's own output or hold the process
267
+ * open after login completes.
268
+ */
269
+ export function openBrowser(url) {
270
+ const opener = process.platform === 'darwin' ? { cmd: 'open', args: [url] }
271
+ : process.platform === 'win32' ? { cmd: 'cmd', args: ['/c', 'start', '', url] }
272
+ : { cmd: 'xdg-open', args: [url] };
273
+ try {
274
+ const child = spawn(opener.cmd, opener.args, { stdio: 'ignore', detached: true });
275
+ child.on('error', () => { });
276
+ child.unref();
277
+ return true;
278
+ }
279
+ catch {
280
+ return false;
281
+ }
282
+ }
283
+ //# sourceMappingURL=oidc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oidc.js","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,YAAY,EAAU,MAAM,MAAM,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAGtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;AAE7D,gFAAgF;AAChF,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AAEzC,8EAA8E;AAC9E,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,QAAQ,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;AAerD,+DAA+D;AAE/D;;;;;GAKG;AACH,SAAS,SAAS,CAAC,GAAW;IAC5B,OAAO,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,cAAc;IAC5B,sEAAsE;IACtE,4CAA4C;IAC5C,MAAM,QAAQ,GAAG,SAAS,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC;IAC5C,MAAM,SAAS,GAAG,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5E,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,SAAS,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC;AACpC,CAAC;AAED,+DAA+D;AAE/D,MAAM,UAAU,iBAAiB,CAAC,IAKjC;IACC,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC;QACjC,aAAa,EAAE,MAAM;QACrB,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ;QAClC,YAAY,EAAE,IAAI,CAAC,WAAW;QAC9B,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC;QACvB,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,cAAc,EAAE,IAAI,CAAC,aAAa;QAClC,qBAAqB,EAAE,MAAM;KAC9B,CAAC,CAAC;IACH,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,qBAAqB,MAAM,EAAE,CAAC;AAC1F,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,SAAwB,EAAE,WAAmB;IAC1E,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC;QACjC,SAAS,EAAE,SAAS,CAAC,QAAQ;QAC7B,UAAU,EAAE,WAAW;KACxB,CAAC,CAAC;IACH,OAAO,GAAG,SAAS,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,WAAW,MAAM,EAAE,CAAC;AAC3E,CAAC;AAED,+DAA+D;AAE/D,oEAAoE;AACpE,SAAS,UAAU,CAAC,KAAa,EAAE,MAAc;IAC/C,OAAO,+CAA+C,KAAK;;;;MAIvD,KAAK,WAAW,MAAM,MAAM,CAAC;AACnC,CAAC;AAOD;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,IAI/B;IACC,OAAO,IAAI,OAAO,CAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrD,IAAI,MAA0B,CAAC;QAC/B,IAAI,OAAO,GAAG,KAAK,CAAC;QACpB,IAAI,KAAiC,CAAC;QAEtC,MAAM,MAAM,GAAG,CAAC,GAAiB,EAAE,MAAuB,EAAE,EAAE;YAC5D,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,IAAI,KAAK;gBAAE,YAAY,CAAC,KAAK,CAAC,CAAC;YAC/B,yEAAyE;YACzE,wEAAwE;YACxE,oEAAoE;YACpE,uEAAuE;YACvE,sEAAsE;YACtE,MAAM,EAAE,KAAK,EAAE,CAAC;YAChB,MAAM,EAAE,mBAAmB,EAAE,EAAE,CAAC;YAChC,IAAI,GAAG;gBAAE,MAAM,CAAC,GAAG,CAAC,CAAC;;gBAChB,OAAO,CAAC,MAAO,CAAC,CAAC;QACxB,CAAC,CAAC;QAEF,uDAAuD;QACvD,MAAM,OAAO,GAAG,CACd,GAAkC,EAClC,MAAc,EACd,IAAY,EACZ,WAAmB,EACnB,IAAgB,EAChB,EAAE;YACF,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC;YAC5E,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACtB,CAAC,CAAC;QAEF,MAAM,OAAO,GAAG,CAAC,KAAa,EAAE,EAAE;YAChC,IAAI,KAAK,IAAI,cAAc,CAAC,MAAM,EAAE,CAAC;gBACnC,MAAM,CAAC,IAAI,KAAK,CACd,4CAA4C,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI;oBACzE,wEAAwE;oBACxE,wEAAwE,CACzE,CAAC,CAAC;gBACH,OAAO;YACT,CAAC;YAED,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;YACnC,MAAM,CAAC,GAAG,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;gBAClC,sEAAsE;gBACtE,oEAAoE;gBACpE,gDAAgD;gBAChD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,oBAAoB,IAAI,EAAE,CAAC,CAAC;gBAChE,IAAI,GAAG,CAAC,QAAQ,KAAK,aAAa,EAAE,CAAC;oBACnC,oEAAoE;oBACpE,iEAAiE;oBACjE,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,YAAY,EAAE,CAAC,CAAC;oBACrD,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;oBACrB,OAAO;gBACT,CAAC;gBAED,MAAM,KAAK,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;gBAC5C,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;gBAC1C,MAAM,aAAa,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;gBAEpD,MAAM,IAAI,GAAG,0BAA0B,CAAC;gBAExC,IAAI,KAAK,EAAE,CAAC;oBACV,MAAM,WAAW,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,mBAAmB,CAAC,IAAI,EAAE,CAAC;oBACpE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,CAAC,gBAAgB,EAAE,GAAG,KAAK,2BAA2B,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAC9F,MAAM,CAAC,IAAI,KAAK,CAAC,yBAAyB,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,MAAM,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAC7F,CAAC;oBACF,OAAO;gBACT,CAAC;gBAED,qEAAqE;gBACrE,8DAA8D;gBAC9D,IAAI,aAAa,KAAK,IAAI,CAAC,KAAK,EAAE,CAAC;oBACjC,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,CAAC,gBAAgB,EAAE,yCAAyC,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CACpG,MAAM,CAAC,IAAI,KAAK,CAAC,0FAA0F,CAAC,CAAC,CAC9G,CAAC;oBACF,OAAO;gBACT,CAAC;gBAED,IAAI,CAAC,IAAI,EAAE,CAAC;oBACV,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,CAAC,gBAAgB,EAAE,6DAA6D,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CACxH,MAAM,CAAC,IAAI,KAAK,CAAC,+CAA+C,CAAC,CAAC,CACnE,CAAC;oBACF,OAAO;gBACT,CAAC;gBAED,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,CAAC,sBAAsB,EAAE,qDAAqD,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CACtH,MAAM,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,oBAAoB,IAAI,GAAG,aAAa,EAAE,EAAE,CAAC,CAChF,CAAC;YACJ,CAAC,CAAC,CAAC;YAEH,CAAC,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAA0B,EAAE,EAAE;gBAC3C,qEAAqE;gBACrE,gCAAgC;gBAChC,IAAI,GAAG,CAAC,IAAI,KAAK,YAAY,IAAI,GAAG,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;oBACvD,CAAC,CAAC,KAAK,EAAE,CAAC;oBACV,OAAO,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;oBACnB,OAAO;gBACT,CAAC;gBACD,MAAM,CAAC,GAAG,CAAC,CAAC;YACd,CAAC,CAAC,CAAC;YAEH,uEAAuE;YACvE,uEAAuE;YACvE,qCAAqC;YACrC,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE;gBAC/B,MAAM,GAAG,CAAC,CAAC;gBACX,MAAM,IAAI,GAAG,CAAC,CAAC,OAAO,EAAiB,CAAC;gBACxC,MAAM,WAAW,GAAG,oBAAoB,IAAI,CAAC,IAAI,GAAG,aAAa,EAAE,CAAC;gBACpE,KAAK,GAAG,UAAU,CAChB,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAC,EAC7F,IAAI,CAAC,SAAS,CACf,CAAC;gBACF,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAY,CAAC,CAAC,CAAC;YAClF,CAAC,CAAC,CAAC;QACL,CAAC,CAAC;QAEF,OAAO,CAAC,CAAC,CAAC,CAAC;IACb,CAAC,CAAC,CAAC;AACL,CAAC;AAED,+DAA+D;AAE/D;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,IAK3C;IACC,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC;QAC/B,UAAU,EAAE,oBAAoB;QAChC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ;QAClC,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,YAAY,EAAE,IAAI,CAAC,WAAW;QAC9B,aAAa,EAAE,IAAI,CAAC,YAAY;KACjC,CAAC,CAAC;IAEH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,eAAe,EAAE;QAC1F,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,mCAAmC,EAAE;QAChE,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE;KACtB,CAAC,CAAC;IAEH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC9B,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,yEAAyE;QACzE,oEAAoE;QACpE,IAAI,MAAM,GAAG,IAAI,CAAC;QAClB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAmD,CAAC;YAClF,MAAM,GAAG,MAAM,CAAC,iBAAiB,IAAI,MAAM,CAAC,KAAK,IAAI,IAAI,CAAC;QAC5D,CAAC;QAAC,MAAM,CAAC,CAAC,uBAAuB,CAAC,CAAC;QACnC,MAAM,IAAI,KAAK,CACb,0BAA0B,GAAG,CAAC,MAAM,MAAM,MAAM,IAAI;YACpD,iFAAiF;YACjF,kEAAkE,CACnE,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAI3B,CAAC;IACF,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;IACtF,CAAC;IACD,OAAO;QACL,OAAO,EAAE,IAAI,CAAC,QAAQ;QACtB,YAAY,EAAE,IAAI,CAAC,aAAa;QAChC,SAAS,EAAE,IAAI,CAAC,UAAU,IAAI,IAAI;KACnC,CAAC;AACJ,CAAC;AAED,+DAA+D;AAE/D;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAAC,OAAe;IAC5C,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACtC,IAAI,CAAC,OAAO;YAAE,OAAO,SAAS,CAAC;QAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAG3E,CAAC;QACF,OAAO,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,kBAAkB,CAAC,IAAI,SAAS,CAAC;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,+DAA+D;AAE/D;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,GAAW;IACrC,MAAM,MAAM,GACV,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE;QAC5D,CAAC,CAAC,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE;YAC/E,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAErC,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QAClF,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,GAA6C,CAAC,CAAC,CAAC;QACvE,KAAK,CAAC,KAAK,EAAE,CAAC;QACd,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC"}
package/dist/wake.d.ts CHANGED
@@ -56,6 +56,86 @@
56
56
  * a future refactor cannot silently reintroduce project scoping.
57
57
  */
58
58
  import type { Command } from 'commander';
59
+ /** A single schedule entry from GET /api/control-plane/wake. */
60
+ interface WakeScheduleEntry {
61
+ role: string;
62
+ auto_wakeable: boolean;
63
+ allow_listed: boolean;
64
+ enabled: boolean;
65
+ }
66
+ /** A single wake fire record from GET /api/control-plane/wake (capped at 50). */
67
+ interface WakeFireEntry {
68
+ id: string;
69
+ dispatch_id: string;
70
+ role: string;
71
+ fire_status: string;
72
+ suppress_code: string | null;
73
+ claimed_at: string;
74
+ fired_at: string | null;
75
+ }
76
+ /**
77
+ * BUG159-A + addendum 2026-08-26: resolved scope returned by the API so the
78
+ * CLI can render the header from the response, not from local config.
79
+ *
80
+ * - allowlist_scope: always 'global' (SSM-sourced, org-agnostic).
81
+ * - disables_scope: always 'org' (wake_disables PK is (org_id, role)).
82
+ * These are level identifiers, NOT slug values.
83
+ * - org_slug: the slug rendered in the disables position of the header:
84
+ * "allowlist: global | disables: <org_slug>"
85
+ * - project_slug: the slug rendered in the location part of the header:
86
+ * "Wake schedule for <org_slug>/<project_slug> (N entries)"
87
+ *
88
+ * NEVER use disables_scope as a display value — it is a level identifier ('org').
89
+ * ALWAYS use org_slug in the disables position.
90
+ */
91
+ interface WakeScopeInfo {
92
+ org_slug: string;
93
+ project_slug: string;
94
+ allowlist_scope: 'global';
95
+ disables_scope: 'org';
96
+ }
97
+ /** A single suppression row from the GET /api/control-plane/wake response (Item 5). */
98
+ interface WakeSuppressionRow {
99
+ event_type: string;
100
+ code: string | null;
101
+ reason: string | null;
102
+ /** AC-33 (r4): the suppression class, kept beside the human-readable reason. */
103
+ subreason?: string | null;
104
+ subject_kind: string | null;
105
+ subject_id: string | null;
106
+ project_id: string | null;
107
+ role: string | null;
108
+ created_at: string;
109
+ org_id: string;
110
+ }
111
+ /** Suppressions block returned by the API (Item 5 — additive, may be absent on older API). */
112
+ interface WakeSuppressionsBlock {
113
+ rows: WakeSuppressionRow[];
114
+ total_count: number;
115
+ truncated: boolean;
116
+ window_start: string;
117
+ }
118
+ export interface WakeGetResponse {
119
+ schedule: WakeScheduleEntry[];
120
+ fires: WakeFireEntry[];
121
+ config?: {
122
+ wake_allowlist_source: string;
123
+ auto_wakeable_roles_source: string;
124
+ };
125
+ /** BUG159-A: resolved scope from the API. */
126
+ scope?: WakeScopeInfo;
127
+ /** Item 5: recent suppression ledger rows. Absent on older API — degrade cleanly. */
128
+ suppressions?: WakeSuppressionsBlock;
129
+ }
130
+ /**
131
+ * Render the "Recent suppressions" block of `fazemos wake status` (Item 5 — §3.5.2g).
132
+ *
133
+ * Exported so T-5h can drive it directly: an older API omits `suppressions`
134
+ * entirely, and this MUST render nothing rather than throw. Every field inside a
135
+ * row is likewise treated as optional — the block degrades, it never crashes a
136
+ * status read the operator is relying on to diagnose a silent wake.
137
+ */
138
+ export declare function renderWakeSuppressions(data: WakeGetResponse): void;
59
139
  /**
60
140
  * Register `wake status`, `wake disable`, and `wake enable` into the root
61
141
  * Commander program. Called from index.ts after `program` is created, alongside
@@ -71,3 +151,4 @@ import type { Command } from 'commander';
71
151
  * on status; disable/enable use req.userOrgId from auth context (no project needed).
72
152
  */
73
153
  export declare function registerWakeCommands(program: Command): void;
154
+ export {};
package/dist/wake.js CHANGED
@@ -55,6 +55,47 @@ function renderScopeHeader(scope, entryCount) {
55
55
  const splitNote = chalk.dim(`(allowlist: ${scope.allowlist_scope} | disables: ${scope.org_slug})`);
56
56
  console.log(chalk.bold(`Wake schedule for ${location} ${splitNote} (${entryCount} entries):`));
57
57
  }
58
+ /**
59
+ * Render the "Recent suppressions" block of `fazemos wake status` (Item 5 — §3.5.2g).
60
+ *
61
+ * Exported so T-5h can drive it directly: an older API omits `suppressions`
62
+ * entirely, and this MUST render nothing rather than throw. Every field inside a
63
+ * row is likewise treated as optional — the block degrades, it never crashes a
64
+ * status read the operator is relying on to diagnose a silent wake.
65
+ */
66
+ export function renderWakeSuppressions(data) {
67
+ // T-5h: older API omits the field — degrade cleanly, render nothing, no error.
68
+ const suppressions = data?.suppressions;
69
+ if (!suppressions || !Array.isArray(suppressions.rows) || suppressions.rows.length === 0)
70
+ return;
71
+ console.log(chalk.bold(`Recent suppressions (${suppressions.total_count}${suppressions.truncated ? '+' : ''}):`));
72
+ console.log();
73
+ for (const row of suppressions.rows) {
74
+ const roleLabel = row.role ? chalk.cyan(row.role.padEnd(32)) : chalk.dim('(no role)'.padEnd(32));
75
+ console.log(` ${roleLabel} ${chalk.yellow(row.event_type)}`);
76
+ if (row.code) {
77
+ console.log(` code: ${chalk.yellow(row.code)}`);
78
+ }
79
+ if (row.reason) {
80
+ console.log(` reason: ${row.reason}`);
81
+ }
82
+ // AC-33 (Dex item-5 F7): the CLASS, printed alongside the explanation.
83
+ // Before r4 the api's `reason` carried the class ('canlaunch_suppressed')
84
+ // and the explanation was never surfaced at all; now `reason` is the
85
+ // explanation and this line keeps the class visible. Optional — an older
86
+ // api omits it and this renders nothing.
87
+ if (row.subreason) {
88
+ console.log(` class: ${chalk.dim(row.subreason)}`);
89
+ }
90
+ if (row.subject_kind && row.subject_id) {
91
+ console.log(` subject: ${row.subject_kind}/${chalk.dim(row.subject_id)}`);
92
+ }
93
+ if (row.created_at) {
94
+ console.log(` at: ${new Date(row.created_at).toLocaleString()}`);
95
+ }
96
+ console.log();
97
+ }
98
+ }
58
99
  // ── Command registration ───────────────────────────────────────────────────────
59
100
  /**
60
101
  * Register `wake status`, `wake disable`, and `wake enable` into the root
@@ -212,26 +253,46 @@ export function registerWakeCommands(program) {
212
253
  console.log();
213
254
  }
214
255
  // ── Recent fires ──────────────────────────────────────────────────────
256
+ // ── AC-17 / T-5o (Dex item-5 F2) — an empty fires list must NOT
257
+ // short-circuit the suppression block ────────────────────────────────
258
+ //
259
+ // This used to `return` here, so `renderWakeSuppressions(data)` below
260
+ // never ran. Post-item-5 a canLaunch-suppressed wake does leave a
261
+ // wake_fires row, so that one class survived the early return — but
262
+ // EVERY decision-predicate denial returns before the claim and writes
263
+ // NO wake_fires row at all (wrong_type, self_wake, worker_gated,
264
+ // not_auto_wakeable, wake_disabled, no_project, no_match_after_ordering,
265
+ // db_error), and so do launch_suppressed and wake_ceiling_hit. Those
266
+ // are the MOST COMMON real causes of "I dispatched and the role never
267
+ // woke" — and for all of them `fires` is empty, so the operator got
268
+ // "No recent wake fires." with the answer sitting one statement below
269
+ // the return. That is the BUG177 shape the ledger exists to close.
215
270
  if (fires.length === 0) {
216
271
  console.log(chalk.dim('No recent wake fires.'));
217
- return;
272
+ console.log();
218
273
  }
219
- console.log(chalk.bold(`Recent wake fires (${fires.length}):`));
220
- console.log();
221
- for (const fire of fires) {
222
- const statusLabel = fireStatusLabel(fire.fire_status);
223
- console.log(` ${chalk.cyan(fire.role.padEnd(32))} ${statusLabel}`);
224
- if (fire.suppress_code) {
225
- console.log(` suppressed: ${chalk.yellow(fire.suppress_code)}`);
226
- }
227
- console.log(` claimed: ${new Date(fire.claimed_at).toLocaleString()}`);
228
- if (fire.fired_at) {
229
- console.log(` fired: ${new Date(fire.fired_at).toLocaleString()}`);
230
- }
231
- console.log(` dispatch_id: ${chalk.dim(fire.dispatch_id)}`);
232
- console.log(` id: ${chalk.dim(fire.id)}`);
274
+ else {
275
+ console.log(chalk.bold(`Recent wake fires (${fires.length}):`));
233
276
  console.log();
277
+ for (const fire of fires) {
278
+ const statusLabel = fireStatusLabel(fire.fire_status);
279
+ console.log(` ${chalk.cyan(fire.role.padEnd(32))} ${statusLabel}`);
280
+ if (fire.suppress_code) {
281
+ console.log(` suppressed: ${chalk.yellow(fire.suppress_code)}`);
282
+ }
283
+ console.log(` claimed: ${new Date(fire.claimed_at).toLocaleString()}`);
284
+ if (fire.fired_at) {
285
+ console.log(` fired: ${new Date(fire.fired_at).toLocaleString()}`);
286
+ }
287
+ console.log(` dispatch_id: ${chalk.dim(fire.dispatch_id)}`);
288
+ console.log(` id: ${chalk.dim(fire.id)}`);
289
+ console.log();
290
+ }
234
291
  }
292
+ // ── Recent suppressions (Item 5) ──────────────────────────────────────
293
+ // Always reached now — AC-17 / T-5o. renderWakeSuppressions degrades on
294
+ // its own for an absent, null, empty or malformed block.
295
+ renderWakeSuppressions(data);
235
296
  }
236
297
  catch (err) {
237
298
  const msg = err instanceof Error ? err.message : String(err);