@cardstack/boxel-cli 0.12.0-unstable.12 → 0.12.0-unstable.14

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.
Files changed (58) hide show
  1. package/bundled-test-harness/assets/{ai-module-creation-test-D9FuBmi8.js → ai-module-creation-test-BuOdKEfn.js} +1 -1
  2. package/bundled-test-harness/assets/{app-B6ALqYQD.js → app-DFQ4JqlP.js} +2 -2
  3. package/bundled-test-harness/assets/{binary-upload-dedupe-test-eI1yGxdL.js → binary-upload-dedupe-test-jbrJQUz0.js} +1 -1
  4. package/bundled-test-harness/assets/{can-invoke-test-5enpsRYF.js → can-invoke-test-DoWzbZ8k.js} +1 -1
  5. package/bundled-test-harness/assets/{card-chooser-paging-test-CdtsXIpu.js → card-chooser-paging-test-VRmsX6sY.js} +1 -1
  6. package/bundled-test-harness/assets/{card-chooser-test-CpUK3_Su.js → card-chooser-test-CgrV_vWi.js} +1 -1
  7. package/bundled-test-harness/assets/{card-def-field-def-relationships-test-Z1O-t73y.js → card-def-field-def-relationships-test-BHQoFm4_.js} +1 -1
  8. package/bundled-test-harness/assets/{card-delete-test-DI16JAzu.js → card-delete-test-DCmoRd03.js} +1 -1
  9. package/bundled-test-harness/assets/{codeblocks-test-DEaQeMcn.js → codeblocks-test-DQU6dnQH.js} +1 -1
  10. package/bundled-test-harness/assets/{compaction-test-DliwzHTd.js → compaction-test-B7JuwjQ7.js} +1 -1
  11. package/bundled-test-harness/assets/{container-BHHKErdI.js → container-CwRfjGxi.js} +1 -1
  12. package/bundled-test-harness/assets/{create-listing-modal-test-BZiD6UL9.js → create-listing-modal-test-CxkuYwnv.js} +1 -1
  13. package/bundled-test-harness/assets/{debug-message-test-D7VEbny3.js → debug-message-test-CyrcsddQ.js} +1 -1
  14. package/bundled-test-harness/assets/{fallback-banner-live-delete-test-DwXohU1S.js → fallback-banner-live-delete-test-Bxxt9EBl.js} +1 -1
  15. package/bundled-test-harness/assets/{fallback-banner-test-jEmNTOeh.js → fallback-banner-test-BzmDOq0F.js} +1 -1
  16. package/bundled-test-harness/assets/{fallback-models-test-BfgkQJ8l.js → fallback-models-test-mZvUg3zI.js} +1 -1
  17. package/bundled-test-harness/assets/{file-attachment-test-CaX44AfG.js → file-attachment-test-ybs1HRMC.js} +1 -1
  18. package/bundled-test-harness/assets/{general-test-zz1QVTIv.js → general-test-jB4EQTh8.js} +1 -1
  19. package/bundled-test-harness/assets/{main-CYiHxOxo.js → main-uvgWNg1U.js} +1 -1
  20. package/bundled-test-harness/assets/{model-cost-tier-test-DcEeQJwF.js → model-cost-tier-test-BPCl0EeO.js} +1 -1
  21. package/bundled-test-harness/assets/{operator-mode-card-chooser-test-DYSf_9jJ.js → operator-mode-card-chooser-test-D8RfBsEc.js} +1 -1
  22. package/bundled-test-harness/assets/{operator-mode-links-test-BA3gjmzY.js → operator-mode-links-test-DmpvnpHI.js} +1 -1
  23. package/bundled-test-harness/assets/{operator-mode-test-C6iSqlfM.js → operator-mode-test-BeCWE8qH.js} +1 -1
  24. package/bundled-test-harness/assets/{operator-mode-ui-test-BFGw2suP.js → operator-mode-ui-test-D0k0pr1Z.js} +1 -1
  25. package/bundled-test-harness/assets/{overlay-menu-items-test-Ch351wNL.js → overlay-menu-items-test-l4-oOOi5.js} +1 -1
  26. package/bundled-test-harness/assets/{panel-B8BaMllL.js → panel-8nNZbvzg.js} +16 -4
  27. package/bundled-test-harness/assets/{past-sessions-test-DoVlENnJ.js → past-sessions-test-DrJ_h58w.js} +1 -1
  28. package/bundled-test-harness/assets/{permission-checking-realm-test-BrBeo81O.js → permission-checking-realm-test-CL_nySH1.js} +1 -1
  29. package/bundled-test-harness/assets/{quick-look-test-InFXyTqv.js → quick-look-test-DMfAGvgd.js} +1 -1
  30. package/bundled-test-harness/assets/{realm-api-keys-test-Bgg-gi56.js → realm-api-keys-test-DVkuepc8.js} +1 -1
  31. package/bundled-test-harness/assets/{realm-config-policy-standing-test-BIEwUQYA.js → realm-config-policy-standing-test-DXS8kYVC.js} +1 -1
  32. package/bundled-test-harness/assets/{realm-config-routing-rule-edit-test-CDj8rdvZ.js → realm-config-routing-rule-edit-test-CPTifvXx.js} +1 -1
  33. package/bundled-test-harness/assets/{realm-config-settings-test-Csi-H8bJ.js → realm-config-settings-test-Civ3ZVxT.js} +1 -1
  34. package/bundled-test-harness/assets/{reasoning-test-MNcXjMV3.js → reasoning-test-DYSQdtDZ.js} +1 -1
  35. package/bundled-test-harness/assets/{room-message-test-DxMyOu8L.js → room-message-test-CSbclniG.js} +1 -1
  36. package/bundled-test-harness/assets/{run-realm-code-test-DTXPkVu_.js → run-realm-code-test-B9KJIaYx.js} +81 -0
  37. package/bundled-test-harness/assets/{scrolling-test-6-Soq39G.js → scrolling-test-nDHEhgzp.js} +2 -2
  38. package/bundled-test-harness/assets/{sending-test-DXOw4MlE.js → sending-test-CjrOIeGV.js} +1 -1
  39. package/bundled-test-harness/assets/{skills-test-BiUE1x5l.js → skills-test-Be4crUyO.js} +1 -1
  40. package/bundled-test-harness/assets/{store-test-Cbf2xZ40.js → store-test-BCyLVXZY.js} +1 -1
  41. package/bundled-test-harness/assets/{tests-Blos5s0A.js → tests-DbU4zMsp.js} +43 -43
  42. package/bundled-test-harness/assets/{text-input-validator-test-BxG8Dc2i.js → text-input-validator-test-DZuVdEKj.js} +1 -1
  43. package/bundled-test-harness/assets/{to-device-streaming-test-CGuKaubC.js → to-device-streaming-test-C3i5WmnX.js} +1 -1
  44. package/bundled-test-harness/assets/{token-usage-test-DJJmXyD8.js → token-usage-test-1AWgVaXO.js} +1 -1
  45. package/bundled-test-harness/assets/{tools-test-DxDvBhPf.js → tools-test-BSmxEyfO.js} +1 -1
  46. package/bundled-test-harness/assets/{turn-settling-test-Co2Dmnzm.js → turn-settling-test-S3ROiZu9.js} +1 -1
  47. package/bundled-test-harness/index.html +5 -5
  48. package/bundled-test-harness/tests/index.html +5 -5
  49. package/bundled-types/host-app/lib/secure-sign-in.ts +5 -3
  50. package/bundled-types/host-app/services/matrix-service.ts +3 -2
  51. package/bundled-types/host-app/services/tool-service.ts +36 -5
  52. package/dist/index.js +290 -290
  53. package/package.json +1 -1
  54. package/src/commands/keys/approve.ts +2 -1
  55. package/src/commands/keys/index.ts +48 -3
  56. package/src/commands/keys/manage.ts +257 -21
  57. package/src/lib/key-bindings.ts +2 -1
  58. package/src/lib/user-keys.ts +174 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.12.0-unstable.12",
3
+ "version": "0.12.0-unstable.14",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -25,6 +25,7 @@ import {
25
25
  type WindowOutcome,
26
26
  } from '../../lib/sso-login.ts';
27
27
  import {
28
+ anonymousCapsClause,
28
29
  findUserKey,
29
30
  listOwnShares,
30
31
  type UserKeyGrant,
@@ -372,7 +373,7 @@ function printBatch(
372
373
  say(
373
374
  ctx,
374
375
  share.anonymous
375
- ? ` ${FG_YELLOW}Anyone on the internet who opens this card, including people who aren't signed in, can make ${operations.join(', ')} with ${share.label}${share.anonymousDailyCap != null ? `, up to ${share.anonymousDailyCap} calls a day all together` : ''}.${RESET}`
376
+ ? ` ${FG_YELLOW}Anyone on the internet who opens this card, including people who aren't signed in, can make ${operations.join(', ')} with ${share.label}${anonymousCapsClause(share)}.${RESET}`
376
377
  : ` This realm lets people who aren't signed in make ${operations.join(', ')}, but ${share.label}'s key allows signed-in people only, so they're asked to sign in.`,
377
378
  );
378
379
  }
@@ -12,6 +12,7 @@ import {
12
12
  keysClients,
13
13
  keysConnect,
14
14
  keysDelete,
15
+ keysLimits,
15
16
  keysList,
16
17
  keysReplace,
17
18
  keysShare,
@@ -19,8 +20,10 @@ import {
19
20
  keysShow,
20
21
  keysSwap,
21
22
  keysUnshare,
23
+ keysUsage,
22
24
  runKeysCommand,
23
25
  signInToManageKeys,
26
+ type LimitsOptions,
24
27
  } from './manage.ts';
25
28
  import {
26
29
  keysApprove,
@@ -175,12 +178,17 @@ export function registerKeysCommand(program: Command): void {
175
178
  '--anonymous-cap <calls>',
176
179
  "With --anonymous, the most calls a day people who aren't signed in make with it, all together, in every realm it's shared into",
177
180
  )
181
+ .option(
182
+ '--anonymous-monthly-cap <calls>',
183
+ "With --anonymous, the most calls a UTC month people who aren't signed in make with it, all together, in every realm it's shared into",
184
+ )
178
185
  .action(
179
186
  (
180
187
  name: string,
181
188
  options: ValueCommandOptions & {
182
189
  anonymous?: boolean;
183
190
  anonymousCap?: string;
191
+ anonymousMonthlyCap?: string;
184
192
  },
185
193
  ) => runKeysCommand(options.json, () => keysAdd(name, options)),
186
194
  );
@@ -188,20 +196,24 @@ export function registerKeysCommand(program: Command): void {
188
196
  keys
189
197
  .command('anonymous')
190
198
  .description(
191
- "Say whether people who aren't signed in may use one of your keys, wherever a realm you share it into lets them make the call. off clears the cap",
199
+ "Say whether people who aren't signed in may use one of your keys, wherever a realm you share it into lets them make the call. off clears both caps; on keeps a cap you don't name",
192
200
  )
193
201
  .argument('<name>', 'The key')
194
202
  .argument('<on|off>', 'Allow it or not')
195
203
  .option(
196
204
  '--cap <calls>',
197
- "With on, the most calls a day people who aren't signed in make with it, all together, in every realm it's shared into",
205
+ "With on, the most calls a day people who aren't signed in make with it, all together, in every realm it's shared into, or none to remove the cap",
206
+ )
207
+ .option(
208
+ '--monthly-cap <calls>',
209
+ "With on, the most calls a UTC month people who aren't signed in make with it, all together, in every realm it's shared into, or none to remove the cap",
198
210
  )
199
211
  .option('--json', 'Output JSON')
200
212
  .action(
201
213
  (
202
214
  name: string,
203
215
  setting: string,
204
- options: { cap?: string; json?: boolean },
216
+ options: { cap?: string; monthlyCap?: string; json?: boolean },
205
217
  ) =>
206
218
  runKeysCommand(options.json, () =>
207
219
  keysAnonymous(name, setting, options),
@@ -366,6 +378,39 @@ export function registerKeysCommand(program: Command): void {
366
378
  runKeysCommand(options.json, () => keysShares(options)),
367
379
  );
368
380
 
381
+ keys
382
+ .command('limits')
383
+ .description(
384
+ "Show or set the limits on how much of one of your keys calls from cards, the AI assistant and the CLI may spend: on a personal key, or with --realm on your share of one there. Each limit takes a number, or default for the platform's. Without a limit to change, shows the limits in force",
385
+ )
386
+ .argument('<name>', 'Your key, or with --realm the label of the share')
387
+ .option('--realm <url>', 'The realm the share is in')
388
+ .option(
389
+ '--rate <burst>/<refill>',
390
+ 'Per caller: how many calls at once, and how many a minute refill, such as 25/15 (default for both puts them back)',
391
+ )
392
+ .option('--daily <n>', 'Calls a UTC day, across every caller')
393
+ .option(
394
+ '--monthly <n>',
395
+ 'Calls a UTC month, across every caller (default: 30 daily budgets)',
396
+ )
397
+ .option('--json', 'Output JSON')
398
+ .action((name: string, options: LimitsOptions) =>
399
+ runKeysCommand(options.json, () => keysLimits(name, options)),
400
+ );
401
+
402
+ keys
403
+ .command('usage')
404
+ .description(
405
+ "Show what one of your keys has served: this UTC day's and month's calls against its budgets, its last use, top callers, refusals by reason and the vendor's answers. With --realm, a share in that realm, for any of the realm's direct writers",
406
+ )
407
+ .argument('<name>', 'Your key, or with --realm the label of the share')
408
+ .option('--realm <url>', 'The realm the share is in')
409
+ .option('--json', 'Output JSON')
410
+ .action((name: string, options: { realm?: string; json?: boolean }) =>
411
+ runKeysCommand(options.json, () => keysUsage(name, options)),
412
+ );
413
+
369
414
  keys
370
415
  .command('clients')
371
416
  .description(
@@ -29,21 +29,28 @@ import {
29
29
  addUserKey,
30
30
  canonicalRealmUrl,
31
31
  deleteUserKey,
32
+ findShare,
32
33
  findUserKey,
34
+ getKeyUsage,
33
35
  listConnectableClients,
34
36
  listOwnShares,
35
37
  listRealmShares,
36
38
  listUserKeys,
37
39
  realmOpensToAnonymous,
38
40
  replaceUserKey,
41
+ setKeyLimits,
39
42
  setUserKeyAnonymousUse,
43
+ anonymousCapsClause,
40
44
  shareUserKey,
41
45
  completeOAuthConnect,
42
46
  startOAuthConnect,
43
47
  swapKeyIntoShare,
44
48
  unshareUserKey,
45
49
  type ConnectableClient,
50
+ type KeyLimitChanges,
51
+ type KeyLimits,
46
52
  type KeyShare,
53
+ type KeyUsage,
47
54
  type OAuthClientSettings,
48
55
  type OAuthConnectOutcome,
49
56
  type ServiceAccountDetails,
@@ -55,12 +62,14 @@ import { finishInSecureWindow, windowTimeout } from './approve.ts';
55
62
  // put them into realms. Keys belong to the user, so only the share commands
56
63
  // name a realm.
57
64
  //
58
- // Reading (`list`, `show`, `shares`, `clients`) and unsharing use the
59
- // profile's ordinary realm-server session. A change that stores or hands out
60
- // a key (`add`, `replace`, `delete`, `share`, `swap`, `connect`) takes a key
61
- // write ticket for that change, which only a session signed in on the secure
62
- // origin can mint, so those commands first make sure the profile holds one,
63
- // opening the secure window's sign-in in the browser when it doesn't.
65
+ // Reading (`list`, `show`, `shares`, `clients`, `usage`, and `limits` with
66
+ // nothing to change) and unsharing use the profile's ordinary realm-server
67
+ // session. A change that stores or hands out a key (`add`, `replace`,
68
+ // `delete`, `share`, `swap`, `connect`) takes a key write ticket for that
69
+ // change, which only a session signed in on the secure origin can mint, so
70
+ // those commands first make sure the profile holds one, opening the secure
71
+ // window's sign-in in the browser when it doesn't. Changing a key's limits
72
+ // takes that session too, with no ticket.
64
73
  //
65
74
  // A key's value is typed at a masked prompt or read with `--stdin` or
66
75
  // `--key-file`, never taken as an argument, and nothing prints one. A refusal
@@ -597,6 +606,7 @@ export async function keysAdd(
597
606
  json?: boolean;
598
607
  anonymous?: boolean;
599
608
  anonymousCap?: string;
609
+ anonymousMonthlyCap?: string;
600
610
  } & KeyValueSource &
601
611
  ServiceAccountOptions &
602
612
  OAuthClientOptions &
@@ -604,10 +614,13 @@ export async function keysAdd(
604
614
  ): Promise<UserKeyMetadata> {
605
615
  let ctx = context(options.json, options);
606
616
  let kind = options.kind ?? 'static';
607
- let anonymousDailyCap = anonymousCapOption(
608
- options.anonymous,
609
- options.anonymousCap,
610
- );
617
+ // A new key has no cap to remove, so these take numbers only.
618
+ let anonymousDailyCap =
619
+ anonymousCapOption(options.anonymous, options.anonymousCap) ?? undefined;
620
+ let anonymousMonthlyCap =
621
+ anonymousCapOption(options.anonymous, options.anonymousMonthlyCap, {
622
+ flag: '--anonymous-monthly-cap',
623
+ }) ?? undefined;
611
624
  if (options.anonymous && kind === 'oauth-client') {
612
625
  throw new Error(
613
626
  "--anonymous isn't taken when adding an OAuth client: a sign-in app's people each connect their own account, so it never serves people who aren't signed in. For one that gets its own tokens (client_credentials), allow it after adding: `boxel keys anonymous <name> on`",
@@ -628,6 +641,7 @@ export async function keysAdd(
628
641
  ...oauthClientSettings(kind, options),
629
642
  ...(options.anonymous ? { anonymous: true } : {}),
630
643
  ...(anonymousDailyCap !== undefined ? { anonymousDailyCap } : {}),
644
+ ...(anonymousMonthlyCap !== undefined ? { anonymousMonthlyCap } : {}),
631
645
  });
632
646
  emit(ctx, key, () => {
633
647
  if (kind === 'oauth-client' && options.grantType !== 'client_credentials') {
@@ -818,31 +832,42 @@ async function warnWhereVisitorsWillUse(
818
832
  }
819
833
  if (!key.anonymous) {
820
834
  throw new Error(
821
- `${realm} lets people who aren't signed in make calls that send a shared key, so ${doing} ${name} there means they will use it, and ${name} doesn't allow them. To put it there, allow it for the key first: \`boxel keys anonymous ${name} on\` (with an optional --cap <calls>)`,
835
+ `${realm} lets people who aren't signed in make calls that send a shared key, so ${doing} ${name} there means they will use it, and ${name} doesn't allow them. To put it there, allow it for the key first: \`boxel keys anonymous ${name} on\` (with an optional --cap <calls> a day and --monthly-cap <calls> a month)`,
822
836
  );
823
837
  }
824
838
  console.error(
825
- `${FG_YELLOW}Warning: ${doing} ${name} into ${realm} means people who aren't signed in will use it. The realm's policy lets anyone on the internet who opens its cards make calls that send a shared key, and read the responses. Their calls count against your quota and/or are billed to your account with the service${key.anonymousDailyCap != null ? `, up to ${key.anonymousDailyCap} calls a day all together` : ''}.${RESET}`,
839
+ `${FG_YELLOW}Warning: ${doing} ${name} into ${realm} means people who aren't signed in will use it. The realm's policy lets anyone on the internet who opens its cards make calls that send a shared key, and read the responses. Their calls count against your quota and/or are billed to your account with the service${anonymousCapsClause(key)}.${RESET}`,
826
840
  );
827
841
  return true;
828
842
  }
829
843
 
830
- // `--anonymous-cap <calls>`, given only with `--anonymous` (or `on`): a whole
831
- // number of calls a day, from 1.
844
+ // An anonymous cap (`--anonymous-cap`, `--anonymous-monthly-cap`, or `--cap`
845
+ // and `--monthly-cap` with `on`), given only with `--anonymous` (or `on`): a
846
+ // whole number of calls, from 1. With `on`, `none` removes that cap (null)
847
+ // and keeps anonymous use on.
832
848
  function anonymousCapOption(
833
849
  anonymous: boolean | undefined,
834
850
  cap: string | undefined,
835
- { flag = '--anonymous-cap', with: withFlag = '--anonymous' } = {},
836
- ): number | undefined {
851
+ {
852
+ flag = '--anonymous-cap',
853
+ with: withFlag = '--anonymous',
854
+ removable = false,
855
+ } = {},
856
+ ): number | null | undefined {
837
857
  if (cap === undefined) {
838
858
  return undefined;
839
859
  }
840
860
  if (!anonymous) {
841
861
  throw new Error(`${flag} is given with ${withFlag}`);
842
862
  }
863
+ if (removable && cap.trim().toLowerCase() === 'none') {
864
+ return null;
865
+ }
843
866
  let calls = Number(cap);
844
867
  if (!/^\d+$/.test(cap.trim()) || calls < 1 || calls > 2_147_483_647) {
845
- throw new Error(`${flag} is a whole number of calls, from 1 to 2147483647`);
868
+ throw new Error(
869
+ `${flag} is a whole number of calls, from 1 to 2147483647${removable ? ', or none' : ''}`,
870
+ );
846
871
  }
847
872
  return calls;
848
873
  }
@@ -850,18 +875,22 @@ function anonymousCapOption(
850
875
  // How a key's setting for people who aren't signed in reads.
851
876
  function anonymousUseOf(key: UserKeyMetadata): string {
852
877
  return key.anonymous
853
- ? `allowed${key.anonymousDailyCap != null ? `, up to ${key.anonymousDailyCap} calls a day all together` : ''}, wherever a realm's policy lets them make the call`
878
+ ? `allowed${anonymousCapsClause(key)}, wherever a realm's policy lets them make the call`
854
879
  : 'not allowed';
855
880
  }
856
881
 
857
882
  // Whether people who aren't signed in may use one of the user's keys: its
858
883
  // owner's setting for the key itself, which every realm it is shared into
859
- // follows where that realm's policy lets them make the call. `off` clears the
860
- // cap.
884
+ // follows where that realm's policy lets them make the call. `off` clears
885
+ // both caps; `on` keeps a cap it doesn't name, and removes one named `none`.
861
886
  export async function keysAnonymous(
862
887
  name: string,
863
888
  setting: string,
864
- options: { cap?: string; json?: boolean } & KeysCommandDeps = {},
889
+ options: {
890
+ cap?: string;
891
+ monthlyCap?: string;
892
+ json?: boolean;
893
+ } & KeysCommandDeps = {},
865
894
  ): Promise<UserKeyMetadata> {
866
895
  if (setting !== 'on' && setting !== 'off') {
867
896
  throw new Error('say on or off');
@@ -870,6 +899,12 @@ export async function keysAnonymous(
870
899
  let anonymousDailyCap = anonymousCapOption(anonymous, options.cap, {
871
900
  flag: '--cap',
872
901
  with: 'on',
902
+ removable: true,
903
+ });
904
+ let anonymousMonthlyCap = anonymousCapOption(anonymous, options.monthlyCap, {
905
+ flag: '--monthly-cap',
906
+ with: 'on',
907
+ removable: true,
873
908
  });
874
909
  let ctx = context(options.json, options);
875
910
  let token = await secureToken(ctx);
@@ -878,6 +913,7 @@ export async function keysAnonymous(
878
913
  keyId: key.id,
879
914
  anonymous,
880
915
  ...(anonymousDailyCap !== undefined ? { anonymousDailyCap } : {}),
916
+ ...(anonymousMonthlyCap !== undefined ? { anonymousMonthlyCap } : {}),
881
917
  });
882
918
  emit(ctx, updated, () => {
883
919
  console.log(
@@ -1017,6 +1053,206 @@ export async function keysShares(
1017
1053
  return shares;
1018
1054
  }
1019
1055
 
1056
+ export interface LimitsOptions {
1057
+ realm?: string;
1058
+ // `<burst>/<refill per minute>`; either side may be `default`.
1059
+ rate?: string;
1060
+ daily?: string;
1061
+ monthly?: string;
1062
+ json?: boolean;
1063
+ }
1064
+
1065
+ // A limit as typed: a number, or `default` for the platform's default. The
1066
+ // realm server says which numbers a limit takes.
1067
+ function limitValue(option: string, value: string): number | null {
1068
+ if (value.trim().toLowerCase() === 'default') {
1069
+ return null;
1070
+ }
1071
+ let number = Number(value);
1072
+ if (value.trim() === '' || !Number.isFinite(number)) {
1073
+ throw new Error(`${option} is a number, or default`);
1074
+ }
1075
+ return number;
1076
+ }
1077
+
1078
+ function limitChanges(options: LimitsOptions): KeyLimitChanges {
1079
+ let changes: KeyLimitChanges = {};
1080
+ if (options.rate !== undefined) {
1081
+ // A bare `default` puts both halves back, as `--daily default` does its one.
1082
+ let parts =
1083
+ options.rate.trim().toLowerCase() === 'default'
1084
+ ? ['default', 'default']
1085
+ : options.rate.split('/');
1086
+ if (parts.length !== 2) {
1087
+ throw new Error(
1088
+ '--rate is <burst>/<refill per minute>, such as 25/15; either may be default, or default for both',
1089
+ );
1090
+ }
1091
+ changes.rateBurst = limitValue('--rate burst', parts[0]!);
1092
+ changes.rateRefillPerMinute = limitValue('--rate refill', parts[1]!);
1093
+ }
1094
+ if (options.daily !== undefined) {
1095
+ changes.dailyBudget = limitValue('--daily', options.daily);
1096
+ }
1097
+ if (options.monthly !== undefined) {
1098
+ changes.monthlyBudget = limitValue('--monthly', options.monthly);
1099
+ }
1100
+ return changes;
1101
+ }
1102
+
1103
+ function printLimits(what: string, limits: KeyLimits): void {
1104
+ console.log(`Limits on ${what}:`);
1105
+ console.log(
1106
+ ` rate: ${limits.rateBurst} calls at once per caller, refilled at ${limits.rateRefillPerMinute} a minute`,
1107
+ );
1108
+ console.log(` daily budget: ${limits.dailyBudget} calls`);
1109
+ console.log(` monthly budget: ${limits.monthlyBudget} calls`);
1110
+ }
1111
+
1112
+ // A share named by its label with `--realm`, or else one of the user's own
1113
+ // keys by name.
1114
+ async function limitedTarget(
1115
+ ctx: Context,
1116
+ token: string,
1117
+ name: string,
1118
+ realm: string | undefined,
1119
+ ): Promise<{
1120
+ what: string;
1121
+ target: { shareId: string } | { keyId: string };
1122
+ limits: KeyLimits | undefined;
1123
+ }> {
1124
+ if (realm) {
1125
+ let share = await findShare(ctx.realmServerUrl, token, {
1126
+ realm,
1127
+ label: name,
1128
+ });
1129
+ return {
1130
+ what: `${share.label} in ${share.realm}`,
1131
+ target: { shareId: share.id },
1132
+ limits: share.limits,
1133
+ };
1134
+ }
1135
+ let key = await findUserKey(ctx.realmServerUrl, token, name);
1136
+ return { what: key.name, target: { keyId: key.id }, limits: key.limits };
1137
+ }
1138
+
1139
+ // Shows or sets the limits on how much of a key interactive calls may spend:
1140
+ // on a personal key, or with `--realm` on a share of one in that realm. Only
1141
+ // the key's owner sets them, with a session signed in on the secure origin;
1142
+ // a realm's other direct writers can read a share's but are refused changing
1143
+ // them, by the realm server. Without a change, prints the limits in force.
1144
+ export async function keysLimits(
1145
+ name: string,
1146
+ options: LimitsOptions & KeysCommandDeps,
1147
+ ): Promise<KeyLimits> {
1148
+ let ctx = context(options.json, options);
1149
+ let changes = limitChanges(options);
1150
+ let changing = Object.keys(changes).length > 0;
1151
+ let token = changing ? await secureToken(ctx) : await ordinaryToken(ctx);
1152
+ let {
1153
+ what,
1154
+ target,
1155
+ limits: current,
1156
+ } = await limitedTarget(ctx, token, name, options.realm);
1157
+ let limits = changing
1158
+ ? await setKeyLimits(ctx.realmServerUrl, token, target, changes)
1159
+ : current;
1160
+ if (!limits) {
1161
+ throw new Error(`The realm server didn't list the limits on ${what}`);
1162
+ }
1163
+ emit(ctx, limits, () => {
1164
+ if (changing) {
1165
+ console.log(`${FG_GREEN}Set the limits on ${what}.${RESET}`);
1166
+ }
1167
+ printLimits(what, limits);
1168
+ });
1169
+ return limits;
1170
+ }
1171
+
1172
+ // The realm server names a caller only to someone who may see them in the
1173
+ // realm: one the reader can't see comes `withheld`, without a name, and a
1174
+ // call with no session comes without a name too.
1175
+ function callerName(
1176
+ ctx: Context,
1177
+ { caller, withheld }: { caller: string | null; withheld?: boolean },
1178
+ ): string {
1179
+ if (withheld) {
1180
+ return "someone you can't see in the realm";
1181
+ }
1182
+ return caller === null
1183
+ ? 'anonymous'
1184
+ : caller === ctx.matrixUserId
1185
+ ? 'you'
1186
+ : caller;
1187
+ }
1188
+
1189
+ function printBudget(label: string, use: KeyUsage['budgets']['day']): void {
1190
+ console.log(
1191
+ ` ${label}: ${use.used} of ${use.budget} calls ${DIM}(resets ${use.resetsAt})${RESET}`,
1192
+ );
1193
+ }
1194
+
1195
+ function printUsage(ctx: Context, what: string, usage: KeyUsage): void {
1196
+ console.log(`Usage of ${what}:`);
1197
+ printBudget('today', usage.budgets.day);
1198
+ printBudget('this month', usage.budgets.month);
1199
+ console.log(` calls since ${usage.since}: ${usage.calls}`);
1200
+ if (usage.lastUse) {
1201
+ let { at, operation, outcome } = usage.lastUse;
1202
+ console.log(
1203
+ ` last use: ${at}, ${operation} by ${callerName(ctx, usage.lastUse)} (${outcome})`,
1204
+ );
1205
+ } else {
1206
+ console.log(' last use: never');
1207
+ }
1208
+ if (usage.topCallers.length > 0) {
1209
+ console.log(' top callers:');
1210
+ for (let entry of usage.topCallers) {
1211
+ console.log(` ${callerName(ctx, entry)}: ${entry.calls}`);
1212
+ }
1213
+ }
1214
+ if (usage.refusals.length > 0) {
1215
+ console.log(' refusals:');
1216
+ for (let { code, count } of usage.refusals) {
1217
+ console.log(` ${code}: ${count}`);
1218
+ }
1219
+ }
1220
+ if (usage.vendorOutcomes.length > 0) {
1221
+ console.log(' vendor answers:');
1222
+ for (let { status, count, outcome } of usage.vendorOutcomes) {
1223
+ console.log(` ${status}${outcome ? ` (${outcome})` : ''}: ${count}`);
1224
+ }
1225
+ }
1226
+ if (usage.grants.length > 0) {
1227
+ console.log(' grants:');
1228
+ for (let grant of usage.grants) {
1229
+ let state =
1230
+ grant.state === 'rejected'
1231
+ ? `${FG_RED}rejected${RESET}${grant.rejectedAt ? ` ${grant.rejectedAt}` : ''}`
1232
+ : grant.state;
1233
+ console.log(` ${grant.destination}: ${state}`);
1234
+ }
1235
+ }
1236
+ printLimits(what, usage.limits);
1237
+ }
1238
+
1239
+ // What a share or a personal key has served: its budgets' use this UTC day
1240
+ // and month, its last use, top callers, refusals by reason, the vendor's
1241
+ // answers and its grants. A personal key's usage is for its owner; a share's,
1242
+ // named by its label with `--realm`, for the realm's direct writers. Reading
1243
+ // needs no secure session.
1244
+ export async function keysUsage(
1245
+ name: string,
1246
+ options: { realm?: string; json?: boolean } & KeysCommandDeps,
1247
+ ): Promise<KeyUsage> {
1248
+ let ctx = context(options.json, options);
1249
+ let token = await ordinaryToken(ctx);
1250
+ let { what, target } = await limitedTarget(ctx, token, name, options.realm);
1251
+ let usage = await getKeyUsage(ctx.realmServerUrl, token, target);
1252
+ emit(ctx, usage, () => printUsage(ctx, what, usage));
1253
+ return usage;
1254
+ }
1255
+
1020
1256
  // How `connect --client` names a client: `platform:<name>` for one of
1021
1257
  // Boxel's own, a share's label for one shared into the realm given with
1022
1258
  // `--realm`, and otherwise the name of one of the user's own client keys.
@@ -97,9 +97,10 @@ export interface BatchShare {
97
97
  covers: string[];
98
98
  interactive: boolean;
99
99
  dailyBudget: number;
100
- // Whether the share's key allows people who aren't signed in, and its cap.
100
+ // Whether the share's key allows people who aren't signed in, and its caps.
101
101
  anonymous?: boolean;
102
102
  anonymousDailyCap?: number | null;
103
+ anonymousMonthlyCap?: number | null;
103
104
  }
104
105
 
105
106
  export interface ApprovalBatch {