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

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/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.13",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -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,
@@ -366,6 +369,39 @@ export function registerKeysCommand(program: Command): void {
366
369
  runKeysCommand(options.json, () => keysShares(options)),
367
370
  );
368
371
 
372
+ keys
373
+ .command('limits')
374
+ .description(
375
+ "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",
376
+ )
377
+ .argument('<name>', 'Your key, or with --realm the label of the share')
378
+ .option('--realm <url>', 'The realm the share is in')
379
+ .option(
380
+ '--rate <burst>/<refill>',
381
+ 'Per caller: how many calls at once, and how many a minute refill, such as 25/15 (default for both puts them back)',
382
+ )
383
+ .option('--daily <n>', 'Calls a UTC day, across every caller')
384
+ .option(
385
+ '--monthly <n>',
386
+ 'Calls a UTC month, across every caller (default: 30 daily budgets)',
387
+ )
388
+ .option('--json', 'Output JSON')
389
+ .action((name: string, options: LimitsOptions) =>
390
+ runKeysCommand(options.json, () => keysLimits(name, options)),
391
+ );
392
+
393
+ keys
394
+ .command('usage')
395
+ .description(
396
+ "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",
397
+ )
398
+ .argument('<name>', 'Your key, or with --realm the label of the share')
399
+ .option('--realm <url>', 'The realm the share is in')
400
+ .option('--json', 'Output JSON')
401
+ .action((name: string, options: { realm?: string; json?: boolean }) =>
402
+ runKeysCommand(options.json, () => keysUsage(name, options)),
403
+ );
404
+
369
405
  keys
370
406
  .command('clients')
371
407
  .description(
@@ -29,13 +29,16 @@ 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,
40
43
  shareUserKey,
41
44
  completeOAuthConnect,
@@ -43,7 +46,10 @@ import {
43
46
  swapKeyIntoShare,
44
47
  unshareUserKey,
45
48
  type ConnectableClient,
49
+ type KeyLimitChanges,
50
+ type KeyLimits,
46
51
  type KeyShare,
52
+ type KeyUsage,
47
53
  type OAuthClientSettings,
48
54
  type OAuthConnectOutcome,
49
55
  type ServiceAccountDetails,
@@ -55,12 +61,14 @@ import { finishInSecureWindow, windowTimeout } from './approve.ts';
55
61
  // put them into realms. Keys belong to the user, so only the share commands
56
62
  // name a realm.
57
63
  //
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.
64
+ // Reading (`list`, `show`, `shares`, `clients`, `usage`, and `limits` with
65
+ // nothing to change) and unsharing use the profile's ordinary realm-server
66
+ // session. A change that stores or hands out a key (`add`, `replace`,
67
+ // `delete`, `share`, `swap`, `connect`) takes a key write ticket for that
68
+ // change, which only a session signed in on the secure origin can mint, so
69
+ // those commands first make sure the profile holds one, opening the secure
70
+ // window's sign-in in the browser when it doesn't. Changing a key's limits
71
+ // takes that session too, with no ticket.
64
72
  //
65
73
  // A key's value is typed at a masked prompt or read with `--stdin` or
66
74
  // `--key-file`, never taken as an argument, and nothing prints one. A refusal
@@ -1017,6 +1025,206 @@ export async function keysShares(
1017
1025
  return shares;
1018
1026
  }
1019
1027
 
1028
+ export interface LimitsOptions {
1029
+ realm?: string;
1030
+ // `<burst>/<refill per minute>`; either side may be `default`.
1031
+ rate?: string;
1032
+ daily?: string;
1033
+ monthly?: string;
1034
+ json?: boolean;
1035
+ }
1036
+
1037
+ // A limit as typed: a number, or `default` for the platform's default. The
1038
+ // realm server says which numbers a limit takes.
1039
+ function limitValue(option: string, value: string): number | null {
1040
+ if (value.trim().toLowerCase() === 'default') {
1041
+ return null;
1042
+ }
1043
+ let number = Number(value);
1044
+ if (value.trim() === '' || !Number.isFinite(number)) {
1045
+ throw new Error(`${option} is a number, or default`);
1046
+ }
1047
+ return number;
1048
+ }
1049
+
1050
+ function limitChanges(options: LimitsOptions): KeyLimitChanges {
1051
+ let changes: KeyLimitChanges = {};
1052
+ if (options.rate !== undefined) {
1053
+ // A bare `default` puts both halves back, as `--daily default` does its one.
1054
+ let parts =
1055
+ options.rate.trim().toLowerCase() === 'default'
1056
+ ? ['default', 'default']
1057
+ : options.rate.split('/');
1058
+ if (parts.length !== 2) {
1059
+ throw new Error(
1060
+ '--rate is <burst>/<refill per minute>, such as 25/15; either may be default, or default for both',
1061
+ );
1062
+ }
1063
+ changes.rateBurst = limitValue('--rate burst', parts[0]!);
1064
+ changes.rateRefillPerMinute = limitValue('--rate refill', parts[1]!);
1065
+ }
1066
+ if (options.daily !== undefined) {
1067
+ changes.dailyBudget = limitValue('--daily', options.daily);
1068
+ }
1069
+ if (options.monthly !== undefined) {
1070
+ changes.monthlyBudget = limitValue('--monthly', options.monthly);
1071
+ }
1072
+ return changes;
1073
+ }
1074
+
1075
+ function printLimits(what: string, limits: KeyLimits): void {
1076
+ console.log(`Limits on ${what}:`);
1077
+ console.log(
1078
+ ` rate: ${limits.rateBurst} calls at once per caller, refilled at ${limits.rateRefillPerMinute} a minute`,
1079
+ );
1080
+ console.log(` daily budget: ${limits.dailyBudget} calls`);
1081
+ console.log(` monthly budget: ${limits.monthlyBudget} calls`);
1082
+ }
1083
+
1084
+ // A share named by its label with `--realm`, or else one of the user's own
1085
+ // keys by name.
1086
+ async function limitedTarget(
1087
+ ctx: Context,
1088
+ token: string,
1089
+ name: string,
1090
+ realm: string | undefined,
1091
+ ): Promise<{
1092
+ what: string;
1093
+ target: { shareId: string } | { keyId: string };
1094
+ limits: KeyLimits | undefined;
1095
+ }> {
1096
+ if (realm) {
1097
+ let share = await findShare(ctx.realmServerUrl, token, {
1098
+ realm,
1099
+ label: name,
1100
+ });
1101
+ return {
1102
+ what: `${share.label} in ${share.realm}`,
1103
+ target: { shareId: share.id },
1104
+ limits: share.limits,
1105
+ };
1106
+ }
1107
+ let key = await findUserKey(ctx.realmServerUrl, token, name);
1108
+ return { what: key.name, target: { keyId: key.id }, limits: key.limits };
1109
+ }
1110
+
1111
+ // Shows or sets the limits on how much of a key interactive calls may spend:
1112
+ // on a personal key, or with `--realm` on a share of one in that realm. Only
1113
+ // the key's owner sets them, with a session signed in on the secure origin;
1114
+ // a realm's other direct writers can read a share's but are refused changing
1115
+ // them, by the realm server. Without a change, prints the limits in force.
1116
+ export async function keysLimits(
1117
+ name: string,
1118
+ options: LimitsOptions & KeysCommandDeps,
1119
+ ): Promise<KeyLimits> {
1120
+ let ctx = context(options.json, options);
1121
+ let changes = limitChanges(options);
1122
+ let changing = Object.keys(changes).length > 0;
1123
+ let token = changing ? await secureToken(ctx) : await ordinaryToken(ctx);
1124
+ let {
1125
+ what,
1126
+ target,
1127
+ limits: current,
1128
+ } = await limitedTarget(ctx, token, name, options.realm);
1129
+ let limits = changing
1130
+ ? await setKeyLimits(ctx.realmServerUrl, token, target, changes)
1131
+ : current;
1132
+ if (!limits) {
1133
+ throw new Error(`The realm server didn't list the limits on ${what}`);
1134
+ }
1135
+ emit(ctx, limits, () => {
1136
+ if (changing) {
1137
+ console.log(`${FG_GREEN}Set the limits on ${what}.${RESET}`);
1138
+ }
1139
+ printLimits(what, limits);
1140
+ });
1141
+ return limits;
1142
+ }
1143
+
1144
+ // The realm server names a caller only to someone who may see them in the
1145
+ // realm: one the reader can't see comes `withheld`, without a name, and a
1146
+ // call with no session comes without a name too.
1147
+ function callerName(
1148
+ ctx: Context,
1149
+ { caller, withheld }: { caller: string | null; withheld?: boolean },
1150
+ ): string {
1151
+ if (withheld) {
1152
+ return "someone you can't see in the realm";
1153
+ }
1154
+ return caller === null
1155
+ ? 'anonymous'
1156
+ : caller === ctx.matrixUserId
1157
+ ? 'you'
1158
+ : caller;
1159
+ }
1160
+
1161
+ function printBudget(label: string, use: KeyUsage['budgets']['day']): void {
1162
+ console.log(
1163
+ ` ${label}: ${use.used} of ${use.budget} calls ${DIM}(resets ${use.resetsAt})${RESET}`,
1164
+ );
1165
+ }
1166
+
1167
+ function printUsage(ctx: Context, what: string, usage: KeyUsage): void {
1168
+ console.log(`Usage of ${what}:`);
1169
+ printBudget('today', usage.budgets.day);
1170
+ printBudget('this month', usage.budgets.month);
1171
+ console.log(` calls since ${usage.since}: ${usage.calls}`);
1172
+ if (usage.lastUse) {
1173
+ let { at, operation, outcome } = usage.lastUse;
1174
+ console.log(
1175
+ ` last use: ${at}, ${operation} by ${callerName(ctx, usage.lastUse)} (${outcome})`,
1176
+ );
1177
+ } else {
1178
+ console.log(' last use: never');
1179
+ }
1180
+ if (usage.topCallers.length > 0) {
1181
+ console.log(' top callers:');
1182
+ for (let entry of usage.topCallers) {
1183
+ console.log(` ${callerName(ctx, entry)}: ${entry.calls}`);
1184
+ }
1185
+ }
1186
+ if (usage.refusals.length > 0) {
1187
+ console.log(' refusals:');
1188
+ for (let { code, count } of usage.refusals) {
1189
+ console.log(` ${code}: ${count}`);
1190
+ }
1191
+ }
1192
+ if (usage.vendorOutcomes.length > 0) {
1193
+ console.log(' vendor answers:');
1194
+ for (let { status, count, outcome } of usage.vendorOutcomes) {
1195
+ console.log(` ${status}${outcome ? ` (${outcome})` : ''}: ${count}`);
1196
+ }
1197
+ }
1198
+ if (usage.grants.length > 0) {
1199
+ console.log(' grants:');
1200
+ for (let grant of usage.grants) {
1201
+ let state =
1202
+ grant.state === 'rejected'
1203
+ ? `${FG_RED}rejected${RESET}${grant.rejectedAt ? ` ${grant.rejectedAt}` : ''}`
1204
+ : grant.state;
1205
+ console.log(` ${grant.destination}: ${state}`);
1206
+ }
1207
+ }
1208
+ printLimits(what, usage.limits);
1209
+ }
1210
+
1211
+ // What a share or a personal key has served: its budgets' use this UTC day
1212
+ // and month, its last use, top callers, refusals by reason, the vendor's
1213
+ // answers and its grants. A personal key's usage is for its owner; a share's,
1214
+ // named by its label with `--realm`, for the realm's direct writers. Reading
1215
+ // needs no secure session.
1216
+ export async function keysUsage(
1217
+ name: string,
1218
+ options: { realm?: string; json?: boolean } & KeysCommandDeps,
1219
+ ): Promise<KeyUsage> {
1220
+ let ctx = context(options.json, options);
1221
+ let token = await ordinaryToken(ctx);
1222
+ let { what, target } = await limitedTarget(ctx, token, name, options.realm);
1223
+ let usage = await getKeyUsage(ctx.realmServerUrl, token, target);
1224
+ emit(ctx, usage, () => printUsage(ctx, what, usage));
1225
+ return usage;
1226
+ }
1227
+
1020
1228
  // How `connect --client` names a client: `platform:<name>` for one of
1021
1229
  // Boxel's own, a share's label for one shared into the realm given with
1022
1230
  // `--realm`, and otherwise the name of one of the user's own client keys.
@@ -95,6 +95,8 @@ export interface UserKeyMetadata {
95
95
  oauth?: OAuthKeyDescription;
96
96
  // Only a google-service-account key has one.
97
97
  serviceAccount?: ServiceAccountDetails;
98
+ // The limits in force, defaults filled in.
99
+ limits?: KeyLimits;
98
100
  createdAt: string;
99
101
  updatedAt: string;
100
102
  // Whether its owner lets people who aren't signed in use it, wherever a
@@ -118,6 +120,9 @@ export interface KeyShare {
118
120
  suspensionReason: string | null;
119
121
  suspendedBy: string | null;
120
122
  suspendedAt: string | null;
123
+ // The limits in force, defaults filled in; listed for the key's owner and
124
+ // the realm's direct writers.
125
+ limits?: KeyLimits;
121
126
  createdAt: string;
122
127
  updatedAt: string;
123
128
  // In a realm's listing: the operations the share is approved for there,
@@ -125,6 +130,22 @@ export interface KeyShare {
125
130
  operations?: KeyShareOperation[];
126
131
  }
127
132
 
133
+ // How much of a key interactive calls may spend: a token bucket per caller
134
+ // (`rateBurst` calls at once, refilled at `rateRefillPerMinute`) and the calls
135
+ // the share or key serves in a UTC day and month, across every caller.
136
+ export interface KeyLimits {
137
+ rateBurst: number;
138
+ rateRefillPerMinute: number;
139
+ dailyBudget: number;
140
+ monthlyBudget: number;
141
+ }
142
+
143
+ // A change to some of a key's limits: a limit left out keeps what it was, and
144
+ // null puts it back to the platform's default.
145
+ export type KeyLimitChanges = Partial<{
146
+ [limit in keyof KeyLimits]: number | null;
147
+ }>;
148
+
128
149
  export interface KeyShareOperation {
129
150
  bindingId: string;
130
151
  type: { module: string; name: string };
@@ -135,6 +156,50 @@ export interface KeyShareOperation {
135
156
  destination?: string;
136
157
  }
137
158
 
159
+ // What a share or a personal key has served, as the realm server records it.
160
+ // The counts cover `since` (the start of the UTC month) until now; the
161
+ // budgets' `used` is what the limits count, interactive calls charged this
162
+ // UTC day and month.
163
+ export interface KeyUsage {
164
+ id: string;
165
+ limits: KeyLimits;
166
+ budgets: {
167
+ day: KeyBudgetUse;
168
+ month: KeyBudgetUse;
169
+ };
170
+ since: string;
171
+ calls: number;
172
+ lastUse: {
173
+ at: string;
174
+ caller: string | null;
175
+ operation: string;
176
+ outcome: 'sent' | 'refused' | 'failed';
177
+ // Set when the caller is someone the reader can't see in the realm.
178
+ withheld?: boolean;
179
+ } | null;
180
+ // A null caller is a call with no session; one marked `withheld` is every
181
+ // caller the reader can't see in the realm, counted together.
182
+ topCallers: { caller: string | null; calls: number; withheld?: boolean }[];
183
+ refusals: { code: string; count: number }[];
184
+ vendorOutcomes: { status: number; count: number; outcome?: string }[];
185
+ grants: {
186
+ id: string;
187
+ host: string;
188
+ destination: string;
189
+ state: 'active' | 'rejected';
190
+ rejectedAt: string | null;
191
+ lastForbiddenAt: string | null;
192
+ }[];
193
+ // Whether the reader may change the limits: only the key's owner may.
194
+ canEditLimits: boolean;
195
+ }
196
+
197
+ export interface KeyBudgetUse {
198
+ used: number;
199
+ budget: number;
200
+ resetsAt: string;
201
+ }
202
+
138
203
  export interface KeyWriteTarget {
139
204
  action: KeyWriteAction;
140
205
  keyId?: string;
@@ -629,3 +694,69 @@ export async function completeOAuthConnect(
629
694
  );
630
695
  return json.data.attributes;
631
696
  }
697
+
698
+ // The user's own share labelled `label` in `realm`, or else the realm's share
699
+ // of that label, for a direct writer of the realm to read or to be refused
700
+ // changing.
701
+ export async function findShare(
702
+ realmServerUrl: string,
703
+ token: string,
704
+ { realm, label }: { realm: string; label: string },
705
+ fetchFn: FetchFn = fetch,
706
+ ): Promise<KeyShare> {
707
+ let canonical = canonicalRealmUrl(realm);
708
+ let own = (await listOwnShares(realmServerUrl, token, {}, fetchFn)).find(
709
+ (share) => share.realm === canonical && share.label === label,
710
+ );
711
+ if (own) {
712
+ return own;
713
+ }
714
+ return findByLabel(
715
+ await listRealmShares(realmServerUrl, token, realm, fetchFn),
716
+ realm,
717
+ label,
718
+ );
719
+ }
720
+
721
+ // Sets some of a share's or a personal key's limits, which only the key's
722
+ // owner may, with a session signed in on the secure origin. Answers the
723
+ // limits in force afterwards, defaults filled in.
724
+ export async function setKeyLimits(
725
+ realmServerUrl: string,
726
+ token: string,
727
+ target: { shareId: string } | { keyId: string },
728
+ changes: KeyLimitChanges,
729
+ fetchFn: FetchFn = fetch,
730
+ ): Promise<KeyLimits> {
731
+ let path =
732
+ 'shareId' in target
733
+ ? `_key-shares/${encodeURIComponent(target.shareId)}/limits`
734
+ : `_user-keys/${encodeURIComponent(target.keyId)}/limits`;
735
+ let json = await realmServerRequest(realmServerUrl, path, {
736
+ method: 'PUT',
737
+ token,
738
+ fetchFn,
739
+ body: { data: { type: 'key-limits', attributes: changes } },
740
+ });
741
+ return json.data.attributes as KeyLimits;
742
+ }
743
+
744
+ // What a share or a personal key has served. A share's usage is for the
745
+ // realm's direct writers, a personal key's for its owner; anyone else is
746
+ // answered 404, as for no such share or key.
747
+ export async function getKeyUsage(
748
+ realmServerUrl: string,
749
+ token: string,
750
+ target: { shareId: string } | { keyId: string },
751
+ fetchFn: FetchFn = fetch,
752
+ ): Promise<KeyUsage> {
753
+ let path =
754
+ 'shareId' in target
755
+ ? `_key-shares/${encodeURIComponent(target.shareId)}/usage`
756
+ : `_user-keys/${encodeURIComponent(target.keyId)}/usage`;
757
+ let json = await realmServerRequest(realmServerUrl, path, {
758
+ token,
759
+ fetchFn,
760
+ });
761
+ return fromResource<KeyUsage>(json.data);
762
+ }