@myapihq/cli 2.17.0 → 2.19.0

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ // Unit tests for the `auth client update` helpers.
2
+ //
3
+ // The subcommand exists because its absence cost a customer a container
4
+ // generation: the PATCH route shipped on 19.08.2026 and the CLI still offered
5
+ // only list|create|delete|rotate, so changing a redirect URI meant delete and
6
+ // recreate — a new client_id, every deployed copy reconfigured, and two
7
+ // audiences accepted through the cutover.
8
+ //
9
+ // Two things here would be silent if they broke: a --redirect that quietly
10
+ // means "replace" while the customer reads "add", and a PATCH with nothing in
11
+ // it answering 200.
12
+ import { describe, it, expect } from 'vitest';
13
+ import { _parseRedirects, _updateInput } from './authproduct.js';
14
+ describe('_parseRedirects', () => {
15
+ it('takes every URI, comma-separated and trimmed', () => {
16
+ expect(_parseRedirects(' https://a.example.com/cb , https://b.example.com/cb '))
17
+ .toEqual(['https://a.example.com/cb', 'https://b.example.com/cb']);
18
+ });
19
+ it('drops a trailing comma rather than sending a blank URI', () => {
20
+ // The backend refuses an empty entry with INVALID_REDIRECT_URI, so passing
21
+ // one on turns a typo into a rejection the customer cannot read.
22
+ expect(_parseRedirects('https://a.example.com/cb,')).toEqual(['https://a.example.com/cb']);
23
+ });
24
+ it('is the same parser create uses', async () => {
25
+ // create and update must agree on what a redirect list is. They had two
26
+ // copies of this line; the second copy is the one that drifts.
27
+ const src = await import('node:fs').then(fs => fs.readFileSync(new URL('./authproduct.ts', import.meta.url), 'utf8'));
28
+ const splits = src.match(/redirect\.split\(/g) || [];
29
+ expect(splits.length, 'a second inline redirect parser has appeared').toBe(0);
30
+ });
31
+ });
32
+ describe('_updateInput', () => {
33
+ it('refuses a PATCH that would change nothing', () => {
34
+ const got = _updateInput('cli_123', '', '');
35
+ expect(got).toHaveProperty('error');
36
+ // The refusal names the next command, which is the house rule for refusals.
37
+ expect(got.error).toContain('myapi auth client update cli_123');
38
+ });
39
+ it('refuses a --redirect that parses to nothing', () => {
40
+ // ` , ` is not "keep what is there" — it replaces the list with an empty
41
+ // one, leaving a client that can never complete a sign-in.
42
+ expect(_updateInput('cli_123', ' , ', '')).toHaveProperty('error');
43
+ });
44
+ it('sends only the fields that were given', () => {
45
+ expect(_updateInput('cli_123', 'https://a.example.com/cb', ''))
46
+ .toEqual({ input: { redirect_uris: ['https://a.example.com/cb'] } });
47
+ expect(_updateInput('cli_123', '', 'Renamed'))
48
+ .toEqual({ input: { name: 'Renamed' } });
49
+ expect(_updateInput('cli_123', 'https://a.example.com/cb', 'Both'))
50
+ .toEqual({ input: { redirect_uris: ['https://a.example.com/cb'], name: 'Both' } });
51
+ });
52
+ });
@@ -1,8 +1,15 @@
1
+ import { auth as sdkAuth } from '@myapihq/sdk';
1
2
  import type { FlagSchema } from '../flags.js';
2
3
  import { type Flags } from '../helpers.js';
3
4
  import type { Exposes } from '../exposes.js';
4
5
  export declare const SCHEMA: FlagSchema;
5
6
  export declare const EXPOSES: Exposes;
6
7
  export declare const SUBCOMMAND_USAGE: Record<string, string>;
7
- export declare const HELP = "Usage: myapi auth <subcommand>\n\nAuthentication for your app's end users \u2014 a managed OIDC identity provider\n(\u00E0 la Kinde/Auth0). One auth tenant per org; register OIDC clients (apps)\nagainst it; sign users in via the hosted login page or the JS SDK; verify\ntokens locally against the tenant JWKS.\n\nEnd users sign in with managed Google, email/password, or magic links \u2014 pick\nwhich via `tenant create --connections`. (Operator/account commands moved to\n`myapi account`.)\n\nSubcommands:\n client Register, list, delete, and rotate OIDC clients (your apps)\n domain Serve auth on your own domain (auth.acme.com)\n tenant Show or create your org's OIDC auth tenant (+ sign-in methods)\n usage Monthly active users (MAU) for the current period";
8
+ export declare const HELP = "Usage: myapi auth <subcommand>\n\nAuthentication for your app's end users \u2014 a managed OIDC identity provider\n(\u00E0 la Kinde/Auth0). One auth tenant per org; register OIDC clients (apps)\nagainst it; sign users in via the hosted login page or the JS SDK; verify\ntokens locally against the tenant JWKS.\n\nEnd users sign in with managed Google, email/password, or magic links \u2014 pick\nwhich via `tenant create --connections`. (Operator/account commands moved to\n`myapi account`.)\n\nSubcommands:\n client Register, list, update, delete, and rotate OIDC clients (your apps)\n domain Serve auth on your own domain (auth.acme.com)\n tenant Show or create your org's OIDC auth tenant (+ sign-in methods)\n usage Monthly active users (MAU) for the current period";
8
9
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
10
+ export declare function _parseRedirects(raw: string): string[];
11
+ export declare function _updateInput(clientId: string, redirect: string, name: string): {
12
+ error: string;
13
+ } | {
14
+ input: sdkAuth.UpdateClientInput;
15
+ };
@@ -22,6 +22,7 @@ export const EXPOSES = [
22
22
  'POST /auth/orgs/{org_id}/clients',
23
23
  'GET /auth/orgs/{org_id}/clients',
24
24
  'GET /auth/orgs/{org_id}/usage',
25
+ 'PATCH /auth/orgs/{org_id}/clients/{client_id}',
25
26
  'DELETE /auth/orgs/{org_id}/clients/{client_id}',
26
27
  'POST /auth/orgs/{org_id}/clients/{client_id}/rotate',
27
28
  'POST /auth/orgs/{org_id}/domain',
@@ -57,12 +58,13 @@ Three steps: set → publish the ownership TXT → verify → publish the A reco
57
58
  Flow: 'set' returns a TXT record to prove ownership; create it, then 'verify'.
58
59
  Once verified, create the printed A record; TLS provisions automatically (~30 min)
59
60
  and the domain becomes your issuer when active.`,
60
- 'client': `myapi auth client <list|create|delete|rotate> [--org <id>] [--json]
61
+ 'client': `myapi auth client <list|create|update|delete|rotate> [--org <id>] [--json]
61
62
 
62
63
  OIDC clients are the apps that authenticate against your tenant.
63
64
 
64
65
  myapi auth client list
65
66
  myapi auth client create --name "My App" --type spa --redirect https://app.example.com/callback
67
+ myapi auth client update <client_id> --redirect https://app.example.com/callback
66
68
  myapi auth client delete <client_id> [--yes]
67
69
  myapi auth client rotate <client_id>
68
70
 
@@ -71,6 +73,9 @@ OIDC clients are the apps that authenticate against your tenant.
71
73
  --redirect <urls> Allowed redirect URIs, comma-separated (required for create).
72
74
  Absolute https (or http://localhost for dev).
73
75
 
76
+ update Change the redirect URIs or the name. Keeps the same client_id and
77
+ secret, so nothing deployed has to be reconfigured. --redirect REPLACES
78
+ the list: pass every URI you want, comma-separated.
74
79
  delete Revoke a client (irreversible; stops authenticating immediately).
75
80
  rotate Re-issue a 'web' client's secret (shown once; old secret stops working).`,
76
81
  };
@@ -86,7 +91,7 @@ which via \`tenant create --connections\`. (Operator/account commands moved to
86
91
  \`myapi account\`.)
87
92
 
88
93
  Subcommands:
89
- client Register, list, delete, and rotate OIDC clients (your apps)
94
+ client Register, list, update, delete, and rotate OIDC clients (your apps)
90
95
  domain Serve auth on your own domain (auth.acme.com)
91
96
  tenant Show or create your org's OIDC auth tenant (+ sign-in methods)
92
97
  usage Monthly active users (MAU) for the current period`;
@@ -287,10 +292,50 @@ async function domain(args, flags) {
287
292
  }
288
293
  error(`Unknown action "${action}". Use: myapi auth domain [show|set|verify|delete]`);
289
294
  }
295
+ /* Redirect URIs, as the CLI accepts them: comma-separated, trimmed, empties
296
+ * dropped. A trailing comma is a typo, not a request for a blank URI — passing
297
+ * one through would earn an INVALID_REDIRECT_URI for something the customer did
298
+ * not mean to send.
299
+ *
300
+ * Shared by create and update rather than written twice: they must agree, and
301
+ * the second copy is the one that drifts. */
302
+ export function _parseRedirects(raw) {
303
+ return raw.split(',').map(s => s.trim()).filter(Boolean);
304
+ }
305
+ /* _updateInput builds the PATCH body, or returns the refusal.
306
+ *
307
+ * Sending neither field is refused rather than treated as a no-op: a PATCH that
308
+ * changes nothing and answers 200 reads as a change that was applied, and the
309
+ * customer finds out at the next sign-in.
310
+ *
311
+ * Returns { error } or { input } so the decision is testable without running
312
+ * the command, which is how the rest of this CLI is tested. */
313
+ export function _updateInput(clientId, redirect, name) {
314
+ if (!redirect && !name) {
315
+ return {
316
+ error: 'Nothing to change. Pass --redirect, --name, or both:\n' +
317
+ ` myapi auth client update ${clientId} --redirect https://app.example.com/callback`,
318
+ };
319
+ }
320
+ const input = {};
321
+ if (redirect) {
322
+ const uris = _parseRedirects(redirect);
323
+ if (uris.length === 0) {
324
+ return {
325
+ error: '--redirect had no URI in it. It REPLACES the list, so an empty ' +
326
+ 'one would leave a client that can never complete a sign-in.',
327
+ };
328
+ }
329
+ input.redirect_uris = uris;
330
+ }
331
+ if (name)
332
+ input.name = name;
333
+ return { input };
334
+ }
290
335
  async function client(args, flags) {
291
336
  const action = args[0] || 'list';
292
337
  const config = requireConfig();
293
- const orgId = requireOrg(flags, config, 'myapi auth client <list|create|delete|rotate> [--org <id>]');
338
+ const orgId = requireOrg(flags, config, 'myapi auth client <list|create|update|delete|rotate> [--org <id>]');
294
339
  if (action === 'list') {
295
340
  const res = await sdkAuth.listClients(config.api_key, orgId);
296
341
  if (flags.json) {
@@ -318,7 +363,7 @@ async function client(args, flags) {
318
363
  error("--type must be 'spa' (public) or 'web' (confidential)");
319
364
  if (!redirect)
320
365
  error('--redirect is required (comma-separate multiple URIs)');
321
- const redirect_uris = redirect.split(',').map(s => s.trim()).filter(Boolean);
366
+ const redirect_uris = _parseRedirects(redirect);
322
367
  const c = await sdkAuth.createClient(config.api_key, orgId, { name, type: type, redirect_uris });
323
368
  if (flags.json) {
324
369
  printJson(c);
@@ -361,6 +406,30 @@ async function client(args, flags) {
361
406
  success(`Client deleted: ${clientId}`);
362
407
  return;
363
408
  }
409
+ if (action === 'update') {
410
+ const clientId = args[1];
411
+ if (!clientId)
412
+ error('Usage: myapi auth client update <client_id> [--redirect <uri,uri>] [--name <name>]');
413
+ // --redirect REPLACES the list, and the CLI says so in the help and again
414
+ // in the success line: a customer adding a second callback URL naturally
415
+ // reads this as "add", and that failure is silent — the first URI stops
416
+ // working and the sign-in that used it breaks at the next deploy, not here.
417
+ const built = _updateInput(clientId, flags.redirect || '', flags.name || '');
418
+ if ('error' in built)
419
+ error(built.error);
420
+ const input = built.input;
421
+ const c = await sdkAuth.updateClient(config.api_key, orgId, clientId, input);
422
+ if (flags.json) {
423
+ printJson(c);
424
+ return;
425
+ }
426
+ success(`Client updated: ${c.client_id || clientId}`);
427
+ if (c.name)
428
+ info(`Name: ${c.name}`);
429
+ info(`Redirects: ${(c.redirect_uris || []).join(', ')}`);
430
+ info('The client_id and secret are unchanged — nothing deployed needs reconfiguring.');
431
+ return;
432
+ }
364
433
  if (action === 'rotate') {
365
434
  const clientId = args[1];
366
435
  if (!clientId)
@@ -379,5 +448,5 @@ async function client(args, flags) {
379
448
  }
380
449
  return;
381
450
  }
382
- error(`Unknown action "${action}". Use: myapi auth client <list|create|delete|rotate>`);
451
+ error(`Unknown action "${action}". Use: myapi auth client <list|create|update|delete|rotate>`);
383
452
  }
@@ -75,7 +75,10 @@ function summarizeContainer(c) {
75
75
  name: c.name,
76
76
  type: c.type,
77
77
  status: c.status,
78
- url: c.url || '(not deployed)',
78
+ // A job has no URL — it runs when triggered. '(not deployed)' in that
79
+ // column read as a broken deploy for a job that was working, which is the
80
+ // column reporting a fault where there is none.
81
+ url: c.url || (c.type === 'job' ? '— (job: runs on trigger)' : '(not deployed)'),
79
82
  updated_at: c.updated_at ? formatDate(c.updated_at) : '',
80
83
  };
81
84
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ // Capture flags send only what the operator named.
2
+ //
3
+ // The rule this file defends: an absent `capture` sub-field means "leave it
4
+ // alone", never "off". ImmoPilot's screens carry tenant names, IBANs and dates
5
+ // of birth; the widget photographs the viewport and reads the text of whatever
6
+ // the reporter points at. If `--no-screenshot` also sent `text: true` because
7
+ // the CLI helpfully filled in the object, it would turn one deliberate
8
+ // restriction into a different, undeclared permission — on exactly those pages.
9
+ //
10
+ // Both fields default to true server-side, so this is also what keeps every
11
+ // widget embedded before capture existed behaving as it always did.
12
+ import { describe, it, expect } from 'vitest';
13
+ import { _captureFrom } from './feedback.js';
14
+ const f = (o) => o;
15
+ describe('_captureFrom', () => {
16
+ it('is undefined when the operator said nothing', () => {
17
+ // Not `{}` — an empty object would still put `capture` in the PATCH body,
18
+ // and "Nothing to change" would stop being reachable.
19
+ expect(_captureFrom(f({}))).toBeUndefined();
20
+ expect(_captureFrom(f({ routes: '/a' }))).toBeUndefined();
21
+ });
22
+ it('sends only the field that was named', () => {
23
+ expect(_captureFrom(f({ 'no-screenshot': true }))).toEqual({ screenshot: false });
24
+ expect(_captureFrom(f({ 'no-text': true }))).toEqual({ text: false });
25
+ expect(_captureFrom(f({ screenshot: true }))).toEqual({ screenshot: true });
26
+ expect(_captureFrom(f({ text: true }))).toEqual({ text: true });
27
+ });
28
+ it('never infers the field it was not told about', () => {
29
+ // The whole point. Turning screenshots off must not carry an opinion about
30
+ // text, in either direction.
31
+ const only = _captureFrom(f({ 'no-screenshot': true }));
32
+ expect(only).not.toHaveProperty('text');
33
+ expect(Object.keys(only)).toEqual(['screenshot']);
34
+ });
35
+ it('carries both when both were named', () => {
36
+ expect(_captureFrom(f({ 'no-screenshot': true, 'no-text': true })))
37
+ .toEqual({ screenshot: false, text: false });
38
+ expect(_captureFrom(f({ screenshot: true, 'no-text': true })))
39
+ .toEqual({ screenshot: true, text: false });
40
+ });
41
+ it('lets the negative win when both spellings are passed', () => {
42
+ // `--screenshot --no-screenshot` is contradictory input. Resolving it to
43
+ // the restrictive answer is the safe direction: the cost of wrongly not
44
+ // capturing is a less useful bug report, the cost of wrongly capturing is
45
+ // a customer's records in a screenshot.
46
+ expect(_captureFrom(f({ screenshot: true, 'no-screenshot': true }))).toEqual({ screenshot: false });
47
+ expect(_captureFrom(f({ text: true, 'no-text': true }))).toEqual({ text: false });
48
+ });
49
+ });
@@ -1,8 +1,16 @@
1
+ import { feedback as sdkFeedback } from '@myapihq/sdk';
1
2
  import type { FlagSchema } from '../flags.js';
2
3
  import { type Flags } from '../helpers.js';
3
4
  import type { Exposes } from '../exposes.js';
4
5
  export declare const EXPOSES: Exposes;
5
6
  export declare const SCHEMA: FlagSchema;
7
+ /** Reads the paired --x / --no-x flags into a capture patch.
8
+ *
9
+ * Only the fields the operator actually named end up in the object. Sending a
10
+ * full `capture` would turn "stop taking screenshots" into "stop taking
11
+ * screenshots and start capturing text", silently changing a setting they did
12
+ * not mention — on a widget that may be pointed at tenant records. */
13
+ export declare function _captureFrom(flags: Flags): sdkFeedback.WidgetCapture | undefined;
6
14
  export declare function list(flags: Flags): Promise<void>;
7
15
  export declare function create(bodyArg: string | undefined, flags: Flags): Promise<void>;
8
16
  export declare function resolve(id: string, flags: Flags): Promise<void>;
@@ -36,7 +36,41 @@ export const SCHEMA = {
36
36
  'page-url': 'string',
37
37
  route: 'string',
38
38
  origins: 'string',
39
+ // Capture controls. Paired --x / --no-x rather than --x=true|false so that
40
+ // "unset" stays distinguishable from "set to false": the API treats an absent
41
+ // key as unchanged, and the CLI must be able to express that.
42
+ screenshot: 'boolean',
43
+ 'no-screenshot': 'boolean',
44
+ text: 'boolean',
45
+ 'no-text': 'boolean',
39
46
  };
47
+ /** Reads the paired --x / --no-x flags into a capture patch.
48
+ *
49
+ * Only the fields the operator actually named end up in the object. Sending a
50
+ * full `capture` would turn "stop taking screenshots" into "stop taking
51
+ * screenshots and start capturing text", silently changing a setting they did
52
+ * not mention — on a widget that may be pointed at tenant records. */
53
+ export function _captureFrom(flags) {
54
+ const cap = {};
55
+ if (flags['no-screenshot'])
56
+ cap.screenshot = false;
57
+ else if (flags.screenshot)
58
+ cap.screenshot = true;
59
+ if (flags['no-text'])
60
+ cap.text = false;
61
+ else if (flags.text)
62
+ cap.text = true;
63
+ return Object.keys(cap).length ? cap : undefined;
64
+ }
65
+ /** "on" / "off" for a resolved capture field. The server returns both fields
66
+ * resolved, so an undefined here means an older backend that did not answer —
67
+ * say so rather than printing "off" for something that is on. */
68
+ function captureLine(c) {
69
+ if (!c)
70
+ return '(not reported by this backend)';
71
+ const s = (v) => v === undefined ? '?' : v ? 'on' : 'off';
72
+ return `screenshot ${s(c.screenshot)}, text ${s(c.text)}`;
73
+ }
40
74
  // The platform's enum, checked against the schema rather than invented.
41
75
  const KINDS = ['bug', 'issue', 'suggestion'];
42
76
  // A selector says `div#install > button.save`. The context says the button read
@@ -96,6 +130,15 @@ export async function list(flags) {
96
130
  const pointing = describeTarget(i);
97
131
  if (pointing)
98
132
  info(` pointing at ${pointing}`);
133
+ // Whoever the host app named via window.__myapiFeedback.identify(). Opaque
134
+ // to us — their id for their user, never resolved against anything.
135
+ //
136
+ // Printed only when set: an anonymous visitor is still the normal case, and
137
+ // a blank `from` reads as a failure to capture rather than nobody to name.
138
+ // Every item filed before 2026-08-19 carries '' — the column existed and
139
+ // nothing ever wrote it — so most history will show no line here.
140
+ if (i.end_user_ref)
141
+ info(` from ${i.end_user_ref}`);
99
142
  const trace = Array.isArray(i.trace) ? i.trace : [];
100
143
  if (trace.length) {
101
144
  // Errors and failed requests are the highest-signal part — for most
@@ -244,7 +287,7 @@ export async function widget(sub, arg, flags) {
244
287
  return;
245
288
  }
246
289
  if (sub === 'update') {
247
- requireArg(arg, 'id', 'myapi feedback widget update <id> [--routes /a,/b] [--origins a.com] [--accent #2563eb] [--position bottom-right] [--label "Feedback"]');
290
+ requireArg(arg, 'id', 'myapi feedback widget update <id> [--routes /a,/b] [--origins a.com] [--accent #2563eb] [--position bottom-right] [--label "Feedback"] [--no-screenshot] [--no-text]');
248
291
  const patch = {};
249
292
  const csv = (v) => typeof v === 'string' ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined;
250
293
  if (flags.routes !== undefined)
@@ -282,7 +325,7 @@ export async function widget(sub, arg, flags) {
282
325
  info('Feedback already collected through it is kept.');
283
326
  return;
284
327
  }
285
- error('Usage: myapi feedback widget create <name> [--origins <list>]\n myapi feedback widget list\n myapi feedback widget update <id> [--routes <list>] [--origins <list>]\n myapi feedback widget revoke <id>');
328
+ error('Usage: myapi feedback widget create <name> [--origins <list>] [--no-screenshot] [--no-text]\n myapi feedback widget list\n myapi feedback widget update <id> [--routes <list>] [--origins <list>] [--no-screenshot] [--no-text]\n myapi feedback widget revoke <id>');
286
329
  }
287
330
  export const SUBCOMMAND_USAGE = {
288
331
  'delete': `myapi feedback delete <id> [--yes] [--org <id>]
@@ -300,10 +343,24 @@ Newest first. \`total\` is the number of matches, not the page size.`,
300
343
  'resolve': 'myapi feedback resolve <id> [--org <id>]',
301
344
  'widget': `myapi feedback widget list
302
345
  myapi feedback widget create <name> [--origins a.com,b.com] [--org <id>]
346
+ myapi feedback widget update <id> [--routes <list>] [--no-screenshot] [--no-text] [--org <id>]
303
347
  myapi feedback widget revoke <id> [--yes] [--org <id>]
304
348
 
305
349
  The key a widget mints is PUBLIC — it ships in page source and authenticates
306
- nobody. --origins stops another site posting through it.`,
350
+ nobody. --origins stops another site posting through it.
351
+
352
+ What a report may collect (both on by default):
353
+ --no-screenshot Stop photographing the viewport.
354
+ --no-text Keep the SHAPE of the element pointed at — tag, role, id,
355
+ data-feedback-id — and drop the words inside it. On a table
356
+ row that is "row, id row-8" instead of the customer's name.
357
+ Pass --screenshot / --text to turn one back on. Flags you omit are left alone,
358
+ so turning one off never silently changes the other. Changes reach live
359
+ browsers within ~5 minutes, with no redeploy of your site.
360
+
361
+ To exempt one panel rather than the whole widget, mark it data-feedback-ignore
362
+ in your page: it is skipped in the element context AND blacked out in the
363
+ screenshot.`,
307
364
  };
308
365
  export async function run(subcommand, args, flags) {
309
366
  if (!subcommand || (flags.help && !subcommand)) {
@@ -320,7 +377,7 @@ Subcommands:
320
377
  widget create <name> Mint a PUBLIC widget key for a site (--origins to restrict)
321
378
  widget list List widgets with the script tag to paste
322
379
  widget revoke <id> Revoke a widget key; collected feedback is kept
323
- widget update <id> Change routes/origins/theme, keeping the same key
380
+ widget update <id> Change routes/origins/theme/capture, keeping the same key
324
381
 
325
382
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
326
383
  return;
package/dist/errors.js CHANGED
@@ -26,6 +26,12 @@ export const ERROR_MESSAGES = {
26
26
  invalid_org: 'Invalid organization.',
27
27
  FORBIDDEN: 'You do not have permission to perform this action.',
28
28
  RATE_LIMITED: 'Too many requests. Please wait a moment and try again.',
29
+ // The backend refuses an unknown key or a non-boolean rather than ignoring it
30
+ // — `{"screenshots": false}` (plural) is a 422, not a no-op. That is
31
+ // deliberate: a capture setting accepted and ignored reads as "screenshots
32
+ // are off" while screenshots keep being taken, on pages that may carry
33
+ // customer records.
34
+ INVALID_CAPTURE: 'capture accepts only `screenshot` and `text`, both booleans. Check the spelling — an unknown key is refused, not ignored.',
29
35
  BODY_TOO_LARGE: 'The request is over 64 KB — usually an oversized feedback `trace`. Nothing was saved. This used to surface as INVALID_JSON, which sent people looking for a syntax error they did not have.',
30
36
  INSUFFICIENT_BALANCE: 'Insufficient balance. Run: myapi billing topup <amount>',
31
37
  INVALID_AMOUNT: 'Amount out of range. Maximum single top-up is $100. Run: myapi billing topup <amount>',
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-auth-api
3
- version: 1.0.0
3
+ version: 1.1.0
4
4
  description: >
5
5
  Add authentication to apps you build on MyAPI — a managed OIDC identity provider for your app's END USERS (à la Kinde/Auth0). One auth tenant per org; register OIDC clients; sign users in with managed Google or the hosted login page; verify RS256 tokens against the tenant JWKS.
6
6
  triggers: [auth, authentication, login, sign-in, oidc, oauth, jwt, jwks, sso, google sign-in, user accounts, identity provider, kinde, auth0, clerk]
7
- checksum: sha256-07690e940717e8f65c195f9fbecdc1def9372edfd52dea19f6570227008d28ad
7
+ checksum: sha256-e6bf7db60560fa6702d7864aaf515a524d3f79cf3eaafe265790005673e3ae29
8
8
  ---
9
9
 
10
10
  # MyAuthAPI
@@ -38,6 +38,11 @@ its clients.
38
38
  client (no secret; for browser/SPA/mobile). `type web` is confidential and
39
39
  returns a `client_secret` **once** — store it immediately. `--redirect` lists
40
40
  allowed callback URIs (absolute https, or http://localhost for dev).
41
+ `auth client update <id> --redirect <uri,uri>` changes them later, keeping the
42
+ same `client_id` and secret. Do NOT delete and recreate to add a callback
43
+ URL — that mints a new `client_id`, so every deployed copy of the app has to
44
+ be reconfigured and the cutover has to accept two audiences at once.
45
+ `--redirect` REPLACES the list: pass every URI you want, not just the new one.
41
46
  - **Usage** — `auth usage` shows monthly active users (auth is billed per MAU).
42
47
  - **Custom domain** — serve auth on `auth.acme.com`. Three steps: `auth domain
43
48
  set --domain auth.acme.com` prints a **TXT ownership challenge**; publish it,
@@ -62,6 +67,7 @@ CLI is only the management surface.
62
67
  | `myapi auth tenant create` | Create/enable the tenant (`--connections google,password,magic`; `--theme <json>`) |
63
68
  | `myapi auth client list` | List the OIDC clients (apps) registered to your tenant |
64
69
  | `myapi auth client create` | Register an OIDC client (`--name`, `--type spa\|web`, `--redirect`) |
70
+ | `myapi auth client update <id>` | Change redirect URIs (`--redirect`, replaces the list) or `--name`; same client_id and secret |
65
71
  | `myapi auth client delete <id>` | Revoke a client (irreversible); `--yes` to skip the confirm |
66
72
  | `myapi auth client rotate <id>` | Re-issue a `web` client's secret (shown once) |
67
73
  | `myapi auth usage` | Monthly active users (MAU) for the current period (`as_of` shows freshness) |
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-feedback-api
3
- version: 1.5.1
3
+ version: 1.6.0
4
4
  description: >
5
5
  Collect feedback from the people using what you built. A public widget key lets a page submit without a credential; you list, filter and resolve the results. Kind is chosen by the person reporting, not inferred from their wording.
6
6
  triggers: [feedback, bug report, user feedback, feature request, widget, support, complaints, praise]
7
- checksum: sha256-b1aba273d6d32b4145dcee85fb59fec35f50b257fc41fb8da9f649a0b519e909
7
+ checksum: sha256-f8be1cee17e2dc5e1dcf2310cf18d275982d54460af26a0b642fc46bd8c4691e
8
8
  ---
9
9
 
10
10
  # MyFeedbackAPI
@@ -18,111 +18,129 @@ Two halves: a **widget key** a page embeds, and the **items** it produces.
18
18
 
19
19
  ### The widget key is public, and that is the point
20
20
 
21
- `myapi feedback widget create <name>` mints a key that ships in your page
22
- source. It is **not a secret** — it names your org so a visitor can submit
23
- without signing in, and it authenticates nobody.
21
+ `widget create <name>` mints a key that ships in your page source. It is **not a
22
+ secret** — it names your org so a visitor can submit without signing in, and
23
+ authenticates nobody.
24
24
 
25
- Treating it as a credential is the mistake to avoid: people hide it, and then
26
- the widget cannot work. What it does need is `--origins`, so another site
27
- cannot post through it:
25
+ Treating it as a credential is the mistake: people hide it and the widget cannot
26
+ work. It needs `--origins`, so another site cannot post through it:
28
27
 
29
28
  ```bash
30
29
  myapi feedback widget create site --origins app.example.com,example.com
31
30
  ```
32
31
 
33
- Revoke with `myapi feedback widget revoke <id>`. Submissions stop immediately;
34
- feedback already collected is kept.
32
+ Revoke with `myapi feedback widget revoke <id>`: submissions stop immediately,
33
+ collected feedback is kept.
35
34
 
36
35
  ### Embedding it: one script tag
37
36
 
38
- The platform hosts the widget, compiled per key with that widget's routes and
39
- theme baked in. This is the intended way in — you do not build a UI:
37
+ The platform hosts the widget, compiled per key with its routes and theme baked
38
+ in. The intended way in — you build no UI:
40
39
 
41
40
  ```html
42
41
  <script src="https://api.myapihq.com/feedback/in/<widget_key>/widget.js" async></script>
43
42
  ```
44
43
 
45
- It renders a button and shows itself only on the routes the widget is
46
- configured for — set them with `widget update <id> --routes /app,/app/**`, and
47
- the change reaches browsers within ~5 minutes with no redeploy of your site.
44
+ It shows only on the widget's routes set them with `widget update <id>
45
+ --routes /app,/app/**`; live in ~5 min, no redeploy.
48
46
 
49
- The person points at an element or drags a region, and it sends:
47
+ The person points at an element or drags a region; it sends:
50
48
 
51
49
  - `kind`, `body`, `page_url`, `route`, `viewport`
52
- - `target_selector` and `target_region` `{x,y,w,h}` — the region is on **every**
53
- report now: the element's bounding rectangle for a click, the dragged box for
54
- a drag
55
- - `target_context` — what the element *was*: its text, role, the heading above it
50
+ - `target_selector` and `target_region` `{x,y,w,h}` — on **every** report: the
51
+ element's bounding box for a click, the dragged box for a drag
52
+ - `target_context` — what the element *was*: text, role, the heading above it
56
53
  - `trace` — the last events before the report
57
54
 
58
- A wrong key does not break the page: the response is `application/javascript`
59
- carrying a JS comment that names the fix, so a typo cannot throw a syntax error
60
- in your app.
55
+ A wrong key cannot break the page: the response is `application/javascript`
56
+ with a comment naming the fix, so a typo throws no syntax error.
61
57
 
62
- **Screenshots are automatic — you do not build a capture step.** When someone
63
- opens the feedback sheet the widget fetches a renderer on demand and attaches
64
- the picture itself, as a second call after the report is saved. That ordering is
65
- deliberate: an image that fails to render or upload never costs the report.
58
+ **Screenshots are automatic — you build no capture step.** The widget attaches
59
+ the picture as a second call after the report is saved, so an image that fails
60
+ to render never costs the report.
66
61
 
67
- The image is **stored private in your org's assets**, and listing feedback
68
- returns a **signed link good for about an hour** (`screenshot_url`). It is
69
- signed on read, so it is not stable re-list rather than persisting it. PNG,
70
- JPEG or WebP up to 3 MB; over that the feedback is kept without the picture.
62
+ The image is **private in your org's assets**; listing returns a **signed link
63
+ good for ~an hour** (`screenshot_url`) re-list rather than persisting it.
64
+ PNG/JPEG/WebP up to 3 MB; over that the report is kept without it.
71
65
 
72
66
  ### Submitting by hand (only if you cannot use the script)
73
67
 
74
- `POST https://api.myapihq.com/feedback/in/<widget_key>` — no auth, no SDK.
75
- Same fields the widget sends:
76
-
77
- ```js
78
- await fetch(`https://api.myapihq.com/feedback/in/${WIDGET_KEY}`, {
79
- method: 'POST',
80
- headers: { 'Content-Type': 'application/json' },
81
- body: JSON.stringify({ kind: 'bug', body: text, page_url: location.href }),
82
- });
83
- ```
68
+ `POST https://api.myapihq.com/feedback/in/<widget_key>` — no auth, no SDK, same
69
+ fields the widget sends (`kind`, `body`, `page_url`, …).
84
70
 
85
71
  Errors: `WIDGET_NOT_FOUND` (a revoked key reads like an invented one, so keys
86
72
  cannot be probed), `ORIGIN_NOT_ALLOWED`, `RATE_LIMITED`, `INVALID_KIND`,
87
- `BODY_REQUIRED`, `BODY_TOO_LONG` (the text is over 8000 chars) and
88
- `BODY_TOO_LARGE` (the whole request is over 64 KB, usually an oversized
89
- `trace`). Nothing is saved in either case. The key only ever writes.
73
+ `BODY_REQUIRED`, `BODY_TOO_LONG` (>8000 chars), `BODY_TOO_LARGE` (>64 KB, usually
74
+ an oversized `trace`). Nothing is saved; the key only writes.
90
75
 
91
76
  ### Kind is a claim, not a guess
92
77
 
93
- `--kind` is one of `bug`, `issue`, `suggestion`, and it is **what the person
94
- reporting says it is**. Someone filing a `bug` is telling you
95
- they believe the product is broken — which is a different and more urgent
78
+ `--kind` is one of `bug`, `issue`, `suggestion`, and it is **what the reporter
79
+ says it is**. A `bug` means they believe the product is broken — a more urgent
96
80
  signal than a wish for something new. Do not re-classify from the wording.
97
81
 
98
- Classification and duplicate-grouping exist in the platform's design and are
99
- not built yet, so treat `kind` as the person's own label rather than a
100
- processed signal.
82
+ Classification and duplicate-grouping are designed but not built, so treat
83
+ `kind` as the person's own label, not a processed signal.
84
+
85
+ ### Controlling what a report contains
86
+
87
+ A report carries a screenshot of the viewport plus the text of the element
88
+ pointed at. On a page of customer records, that is the page. Both on by default:
89
+
90
+ ```bash
91
+ myapi feedback widget update <id> --no-screenshot # stop photographing
92
+ myapi feedback widget update <id> --no-text # keep SHAPE, drop words
93
+ ```
94
+
95
+ `--no-text` keeps tag, role, id and `data-feedback-id`: on a table row, "row, id
96
+ `row-8`" not the tenant's name. `--screenshot` / `--text` turn one back on.
97
+ Omitted flags are left alone, so one off never changes the other. Live in ~5 min.
98
+
99
+ **In your page**, two levers only you control:
100
+
101
+ ```js
102
+ window.__myapiFeedback.identify("your-user-id"); // when you learn who it is
103
+ window.__myapiFeedback.identify(); // on sign-out
104
+ ```
105
+
106
+ Callable any time, even long after load. Your id for your user — opaque to us,
107
+ never resolved, posted to a public endpoint: a label, not authentication. Shows
108
+ as `from` in `feedback list`.
109
+
110
+ Mark an element `data-feedback-ignore` to skip it in the context AND black it out
111
+ in the screenshot.
112
+
113
+ | Need | Lever |
114
+ |---|---|
115
+ | No pictures at all | `--no-screenshot` |
116
+ | Pictures, no words | `--no-text` |
117
+ | All but one panel | `data-feedback-ignore` |
118
+ | Different rules per page | two widgets, two route sets |
119
+
120
+ The last row is usually right: capture off on record-bearing routes, on
121
+ elsewhere — not one global switch.
101
122
 
102
123
  ### Reading it back
103
124
 
104
125
  Each item carries what was pointed at, how they got there, and a picture.
105
- `myapi feedback list` summarises that per report — the element's own text rather
106
- than its CSS selector, a count of steps/errors/failed requests, and whether a
107
- screenshot exists. `--trace` expands the event list; `--json` passes everything
108
- through.
109
-
110
- Two things not to misread: **`trace[].t` is milliseconds since page load**, not
111
- a clock time, so it only means anything as a delta within one trace. And
112
- **`target_context.filled` says an input had a value, never what it was** — the
113
- widget does not collect it.
114
-
115
- `delete` removes the screenshot along with the report, which is the case that
116
- matters: somebody erasing a report because of what is in the picture.
117
-
118
- `myapi feedback list` is newest first, filterable by `--kind` and `--status`
119
- (`open` | `resolved`), paged with `--limit` / `--offset`. **`total` is the
120
- number of matches, not the size of the page**, and `has_more` flags a truncated
121
- result both describe the whole match set.
122
-
123
- `myapi feedback resolve <id>` closes an item. An unknown id answers the same
124
- way as an already-resolved one, so a success is not proof the item existed —
125
- that is deliberate, so ids cannot be probed across orgs.
126
+ `list` summarises it — the element's own text rather than its CSS selector,
127
+ counts of steps/errors/failed requests, whether a screenshot exists, and `from`
128
+ when the page called `identify()`. `--trace` expands the events; `--json` passes
129
+ everything through.
130
+
131
+ Two not to misread: **`trace[].t` is ms since page load**, not clock time — only
132
+ a delta means anything. And **`target_context.filled` says an input had a value,
133
+ never what**.
134
+
135
+ `delete` removes the screenshot with the report — what matters is somebody
136
+ erasing a report because of what is in the picture.
137
+
138
+ `list` is newest first, filterable by `--kind`/`--status`, paged with `--limit`
139
+ / `--offset`. **`total` is the match count, not the page size**; `has_more` flags
140
+ truncation both describe the whole match set.
141
+
142
+ `resolve <id>` closes an item. An unknown id answers like an already-resolved
143
+ one, so success is not proof it existed — deliberate, so ids cannot be probed.
126
144
  <!-- llm:end -->
127
145
 
128
146
  ## Commands
@@ -135,7 +153,7 @@ that is deliberate, so ids cannot be probed across orgs.
135
153
  | `myapi feedback delete <id>` | Erase an item — for spam, or personal details typed into the box |
136
154
  | `myapi feedback widget create <name> [--origins a.com,b.com]` | Mint a PUBLIC widget key for a site |
137
155
  | `myapi feedback widget list` | List widgets, each with the script tag to paste |
138
- | `myapi feedback widget update <id>` | Change `--routes`, `--origins`, `--name`, or the look (`--accent`, `--position`, `--label`), keeping the same key |
156
+ | `myapi feedback widget update <id>` | Change `--routes`, `--origins`, `--name`, look (`--accent`/`--position`/`--label`) or capture (`--no-screenshot`/`--no-text`); same key |
139
157
  | `myapi feedback widget revoke <id>` | Revoke a key; collected feedback is kept |
140
158
  <!-- generated:end -->
141
159
 
@@ -149,16 +167,16 @@ myapi feedback widget create marketing --origins example.com,www.example.com
149
167
  # 2. See your widgets and the exact tag to paste
150
168
  myapi feedback widget list
151
169
 
152
- # 3. Change where it appears same key, so your page needs no edit
170
+ # 3. Change where it appears, or what it may collect same key either way
153
171
  myapi feedback widget update <id> --routes /app,/app/**
172
+ myapi feedback widget update <id> --no-screenshot --no-text # record-bearing routes
154
173
 
155
- # 4. Read what came back, newest first
174
+ # 4. Read what came back, newest first (`from` shows if the page identify()d)
156
175
  myapi feedback list --status open
157
176
  myapi feedback list --kind bug --limit 20
158
177
 
159
178
  # 5. Record something yourself (support call, your own testing)
160
- myapi feedback create "checkout 500s on the second attempt" --kind bug \
161
- --route /checkout
179
+ myapi feedback create "checkout 500s on retry" --kind bug --route /checkout
162
180
 
163
181
  # 6. Close it — or delete it, which also removes its screenshot
164
182
  myapi feedback resolve <id>
@@ -168,12 +186,11 @@ myapi feedback delete <id> --yes
168
186
 
169
187
  ## Notes
170
188
 
171
- - The widget key is public by design — restrict it with `--origins` rather than hiding it.
172
- - `resolve` is idempotent and deliberately indistinguishable from an unknown id.
189
+ - The widget key is public by design — restrict with `--origins`, don't hide it.
173
190
  - Feedback survives widget revocation.
174
- - **`resolve` keeps, `delete` erases.** A public inbox collects spam and the
175
- occasional report with personal details in it; `delete` is how those leave.
176
- Both are idempotent and answer the same for an unknown id.
191
+ - **`resolve` keeps, `delete` erases.** A public inbox collects spam and the odd
192
+ report with personal details in it; `delete` is how those leave. Both are
193
+ idempotent and answer the same for an unknown id, so ids cannot be probed.
177
194
 
178
195
  ## HTTP (from deployed code)
179
196
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.17.0",
4
+ "version": "2.19.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "prebuild": "node scripts/copy-skills.js",
21
- "build": "tsc && rm -rf dist/skills && cp -r src/skills dist/skills",
21
+ "build": "rm -rf dist && tsc && cp -r src/skills dist/skills",
22
22
  "dev": "tsc --watch",
23
23
  "test": "vitest run src test/scripts",
24
24
  "test:smoke": "npm run build && vitest run src test/smoke test/scripts",
@@ -46,7 +46,7 @@
46
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
47
47
  },
48
48
  "dependencies": {
49
- "@myapihq/sdk": "^2.17.0"
49
+ "@myapihq/sdk": "^2.19.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",