@faable/faable 4.6.0 → 4.8.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.
Files changed (32) hide show
  1. package/dist/api/FaableApi.js +6 -0
  2. package/dist/api/auth_admin.js +26 -1
  3. package/dist/commands/auth/accounts/index.js +11 -4
  4. package/dist/commands/auth/connections/index.js +10 -0
  5. package/dist/commands/auth/connections/list.js +48 -0
  6. package/dist/commands/auth/index.js +5 -1
  7. package/dist/commands/auth/logs/list.js +28 -9
  8. package/dist/commands/auth/query.js +17 -9
  9. package/dist/commands/auth/sessions/index.js +15 -0
  10. package/dist/commands/auth/sessions/list.js +73 -0
  11. package/dist/commands/auth/sessions/revoke.js +96 -0
  12. package/dist/commands/auth/users/get.js +6 -4
  13. package/dist/commands/auth/users/ids.js +4 -2
  14. package/dist/commands/auth/users/index.js +3 -1
  15. package/dist/commands/auth/users/list.js +89 -5
  16. package/dist/commands/auth/users/password_setup.js +54 -0
  17. package/dist/commands/auth/users/reinstate.js +8 -3
  18. package/dist/commands/auth/users/resolve.js +36 -0
  19. package/dist/commands/auth/users/suspend.js +8 -3
  20. package/dist/commands/deploy/apps/create.js +35 -2
  21. package/dist/commands/deploy/github/index.js +10 -0
  22. package/dist/commands/deploy/github/repos.js +70 -0
  23. package/dist/commands/deploy/index.js +2 -0
  24. package/dist/commands/deploy/inspect/inspect.js +18 -1
  25. package/dist/commands/deploy/inspect/trigger.js +28 -0
  26. package/dist/commands/deploy/launch.js +62 -2
  27. package/dist/commands/deploy/wait.js +54 -0
  28. package/dist/mcp/http.js +8 -6
  29. package/dist/mcp/oauth.js +12 -6
  30. package/dist/mcp/server.js +14 -4
  31. package/dist/mcp/tools.js +427 -6
  32. package/package.json +1 -1
@@ -213,6 +213,12 @@ class FaableApi {
213
213
  async getDeployment(deployment_id) {
214
214
  return data(this.client.get(`/deployment/${deployment_id}`));
215
215
  }
216
+ // Whose failure it is (user | platform | unknown) and its stable code —
217
+ // the api's own fault attribution. Older apis don't have it: callers treat
218
+ // a 404 as "no verdict".
219
+ async getDeploymentFault(deployment_id) {
220
+ return data(this.client.get(`/deployment/${deployment_id}/fault`));
221
+ }
216
222
  // Build payload of a deployment (runnable descriptor: runtime, profile,
217
223
  // size, checksum). Authorized by the caller's access to the parent
218
224
  // deployment, so no team header is needed — same posture as getDeployment.
@@ -8,6 +8,7 @@ import { FaableApi } from './FaableApi.js';
8
8
  import { AUTH_DOMAIN, createAnonymousAuthApi, createBearerAuthApi } from './auth.js';
9
9
  import { requireProject } from './project.js';
10
10
  import { loadLiveCredentials, isTokenLive } from './session.js';
11
+ import { apikey_strategy } from './strategies/apikey.strategy.js';
11
12
  import { bearer_strategy } from './strategies/bearer.strategy.js';
12
13
 
13
14
  // The session bearer: FAABLE_TOKEN, else `faable login` (auto-refreshed).
@@ -75,6 +76,12 @@ const chooseAccount = async (project, accounts, store = new ContextStore()) => {
75
76
  // per-tenant token the deploy api issues (cached until it expires); the
76
77
  // session bearer is the fallback where no such token exists.
77
78
  const requireAuthAdmin = async (opts = {}) => {
79
+ // A deploy API key (the hosted MCP runs each call with the caller's key):
80
+ // the api issues a narrower tenant token for the tenants of the key's own
81
+ // project. Same precedence as `context()`: FAABLE_TOKEN wins over it.
82
+ if (!process.env.FAABLE_TOKEN && process.env.FAABLE_API_KEY) {
83
+ return requireAuthAdminWithApiKey(opts, process.env.FAABLE_API_KEY);
84
+ }
78
85
  const token = await requireSessionToken();
79
86
  const deploy = FaableApi.create({
80
87
  authStrategy: bearer_strategy,
@@ -89,6 +96,24 @@ const requireAuthAdmin = async (opts = {}) => {
89
96
  account: tenant.account
90
97
  });
91
98
  };
99
+ // With an API key there is no session bearer to fall back on: no tenant
100
+ // token is a refusal, not "use your own token". Not cached — the hosted
101
+ // server gives each call an empty HOME, and a key's token is narrower than
102
+ // its owner's, so it must never be served to a later session.
103
+ const requireAuthAdminWithApiKey = async (opts, apikey, deploy = FaableApi.create({ authStrategy: apikey_strategy, auth: { apikey } })) => {
104
+ const tenant = await resolveTenant(opts, deploy);
105
+ if (!tenant.account) {
106
+ throw new CliError('usage', `Could not tell which Auth tenant ${tenant.domain} is. Pass --account <account_id>.`);
107
+ }
108
+ const issued = await issueTenantToken('', tenant.account, deploy);
109
+ if (!issued) {
110
+ throw new CliError('forbidden', `No management token for ${tenant.account} with an API key. It only works for the Auth tenants of the key's own project.`);
111
+ }
112
+ return createBearerAuthApi(issued, {
113
+ domain: tenant.domain,
114
+ account: tenant.account
115
+ });
116
+ };
92
117
  // Every `faable auth` call used to mint a fresh tenant token — one extra
93
118
  // round trip per command, and a burst of them from a script or the MCP
94
119
  // server. It lives for minutes (`expires_in`), so it's kept next to the
@@ -209,4 +234,4 @@ const withAuthHints = (handler) => {
209
234
  };
210
235
  };
211
236
 
212
- export { TenantTokenCache, cachedTenantToken, chooseAccount, hintAuthError, issueTenantToken, requireAuthAdmin, requireSessionToken, resolveAccountByHost, resolveTenant, subject, withAuthHints };
237
+ export { TenantTokenCache, cachedTenantToken, chooseAccount, hintAuthError, issueTenantToken, requireAuthAdmin, requireAuthAdminWithApiKey, requireSessionToken, resolveAccountByHost, resolveTenant, subject, withAuthHints };
@@ -1,15 +1,22 @@
1
1
  import { requireSessionToken } from '../../../api/auth_admin.js';
2
2
  import { FaableApi } from '../../../api/FaableApi.js';
3
3
  import { requireProject } from '../../../api/project.js';
4
+ import { apikey_strategy } from '../../../api/strategies/apikey.strategy.js';
4
5
  import { bearer_strategy } from '../../../api/strategies/bearer.strategy.js';
5
6
  import { ContextStore } from '../../../lib/ContextStore.js';
6
7
  import { fetch_page, print_json, table_lines, print, more_hint, list_options } from '../../../lib/listing.js';
7
8
  import { log } from '../../../log.js';
8
9
 
9
- const deploy_api = async () => FaableApi.create({
10
- authStrategy: bearer_strategy,
11
- auth: { token: await requireSessionToken() }
12
- });
10
+ // A deploy API key (the hosted MCP) lists its own project's tenants too.
11
+ const deploy_api = async () => !process.env.FAABLE_TOKEN && process.env.FAABLE_API_KEY
12
+ ? FaableApi.create({
13
+ authStrategy: apikey_strategy,
14
+ auth: { apikey: process.env.FAABLE_API_KEY }
15
+ })
16
+ : FaableApi.create({
17
+ authStrategy: bearer_strategy,
18
+ auth: { token: await requireSessionToken() }
19
+ });
13
20
  // `faable auth accounts list` — the Auth tenants of the active project, the
14
21
  // ones `faable auth` can manage without --account.
15
22
  const accounts_list = {
@@ -0,0 +1,10 @@
1
+ import { connections_list } from './list.js';
2
+
3
+ const connections = {
4
+ command: 'connections',
5
+ describe: 'List the login methods of the tenant',
6
+ builder: yargs => yargs.command(connections_list).demandCommand(1).showHelpOnFail(false),
7
+ handler: () => { }
8
+ };
9
+
10
+ export { connections };
@@ -0,0 +1,48 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { list_options, fetch_page, from_paginator, print_json, table_lines, print, more_hint } from '../../../lib/listing.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { yes_no } from '../render.js';
6
+
7
+ // The tenant's login methods: social providers (Google, GitHub…),
8
+ // passwordless (email/SMS codes) and username-password databases.
9
+ const connections_list = {
10
+ command: 'list',
11
+ describe: 'List login methods (social, passwordless, database)',
12
+ builder: yargs => list_options(tenant_options(yargs))
13
+ .option('enabled', {
14
+ type: 'boolean',
15
+ description: 'Only enabled (or, with --no-enabled, disabled) connections'
16
+ })
17
+ .showHelpOnFail(false),
18
+ handler: withAuthHints(async (args) => {
19
+ const api = await requireAuthAdmin(args);
20
+ const page = await fetch_page(from_paginator(api.connectionList({
21
+ ...(args.enabled !== undefined ? { enabled: String(args.enabled) } : {})
22
+ })), args);
23
+ // Never the provider secrets: name, type and whether it is on.
24
+ const data = page.data.map((c) => ({
25
+ id: c.id,
26
+ connection_name: c.connection_name,
27
+ connection_type: c.connection_type,
28
+ enabled: !!c.enabled,
29
+ enabled_clients: Array.isArray(c.enabled_clients)
30
+ ? c.enabled_clients.length
31
+ : undefined
32
+ }));
33
+ if (args.json)
34
+ return print_json({ ...page, data });
35
+ if (data.length === 0) {
36
+ log.info('📭 No connections.');
37
+ return;
38
+ }
39
+ for (const line of table_lines(['ID', 'NAME', 'TYPE', 'ENABLED'], data.map(c => [c.id, c.connection_name ?? '-', c.connection_type ?? '-', yes_no(c.enabled)]))) {
40
+ print(line);
41
+ }
42
+ const hint = more_hint(page);
43
+ if (hint)
44
+ log.info(hint);
45
+ })
46
+ };
47
+
48
+ export { connections_list };
@@ -1,7 +1,9 @@
1
1
  import { accounts, auth_use } from './accounts/index.js';
2
2
  import { actions } from './actions/index.js';
3
3
  import { clients } from './clients/index.js';
4
+ import { connections } from './connections/index.js';
4
5
  import { logs } from './logs/index.js';
6
+ import { sessions } from './sessions/index.js';
5
7
  import { users } from './users/index.js';
6
8
 
7
9
  // `faable auth` — management commands for a Faable Auth tenant. Auth: reuses
@@ -10,12 +12,14 @@ import { users } from './users/index.js';
10
12
  // resolveTenant in src/api/auth_admin.ts.
11
13
  const auth = {
12
14
  command: 'auth',
13
- describe: 'Manage Faable Auth (users, actions, clients, audit logs)',
15
+ describe: 'Manage Faable Auth (users, sessions, logins, actions, clients, audit logs)',
14
16
  builder: yargs => yargs
15
17
  .command(users)
16
18
  .command(actions)
17
19
  .command(clients)
18
20
  .command(logs)
21
+ .command(sessions)
22
+ .command(connections)
19
23
  .command(accounts)
20
24
  .command(auth_use)
21
25
  .demandCommand(1)
@@ -2,7 +2,8 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
3
  import { list_options, fetch_page, from_paginator, print_json, table_lines, print, more_hint } from '../../../lib/listing.js';
4
4
  import { tenant_options } from '../options.js';
5
- import { compose_query, term, time_term } from '../query.js';
5
+ import { compose_query, term, range_term } from '../query.js';
6
+ import { resolve_user_ref } from '../users/resolve.js';
6
7
  import { log_status_badge, truncate } from '../render.js';
7
8
 
8
9
  const logs_list = {
@@ -33,6 +34,14 @@ const logs_list = {
33
34
  .option('user', {
34
35
  type: 'string',
35
36
  description: 'Filter by subject user id'
37
+ })
38
+ .option('email', {
39
+ type: 'string',
40
+ description: "Filter by the subject user's email (exactly one user)"
41
+ })
42
+ .option('expand-user', {
43
+ type: 'boolean',
44
+ description: 'Embed each entry\'s user (email, name) instead of only its id'
36
45
  })
37
46
  .option('client', {
38
47
  type: 'string',
@@ -40,27 +49,35 @@ const logs_list = {
40
49
  })
41
50
  .option('since', {
42
51
  type: 'string',
43
- description: 'From date: unix-millis or YYYY-MM-DD'
52
+ description: 'From: a relative age (30m, 24h, 7d), unix-millis or YYYY-MM-DD'
44
53
  })
45
54
  .option('until', {
46
55
  type: 'string',
47
- description: 'To date: unix-millis or YYYY-MM-DD'
56
+ description: 'To: a relative age (30m, 24h, 7d), unix-millis or YYYY-MM-DD'
48
57
  })
49
58
  .example('$0 auth logs list --user user_abc123 --since 2026-08-01', "One user's audit trail since August 1st")
50
59
  .example('$0 auth logs list --origin oauth --status failed', 'Failed OAuth events')
60
+ .example('$0 auth logs list --type user.login --since 24h --expand-user', 'Who logged in in the last 24 hours')
51
61
  .showHelpOnFail(false),
52
62
  handler: withAuthHints(async (args) => {
63
+ const api = await requireAuthAdmin(args);
64
+ const user = args.email
65
+ ? await resolve_user_ref(api, args.email)
66
+ : args.user;
53
67
  const query = compose_query([
54
68
  term('type', args.type),
55
69
  term('status', args.status),
56
70
  term('origin', args.origin),
57
- term('user', args.user),
71
+ term('user', user),
58
72
  term('client', args.client),
59
- time_term('since', args.since),
60
- time_term('until', args.until)
73
+ range_term('since', 'since', args.since),
74
+ range_term('until', 'until', args.until)
61
75
  ], args.query);
62
- const api = await requireAuthAdmin(args);
63
- const page = await fetch_page(from_paginator(api.logList({ query, q: args.q })), args);
76
+ const page = await fetch_page(from_paginator(api.logList({
77
+ query,
78
+ q: args.q,
79
+ ...(args.expandUser ? { expand: ['user'] } : {})
80
+ })), args);
64
81
  if (args.json)
65
82
  return print_json(page);
66
83
  if (page.data.length === 0) {
@@ -74,7 +91,9 @@ const logs_list = {
74
91
  log_status_badge(entry.status),
75
92
  typeof entry.user === 'string'
76
93
  ? entry.user
77
- : (entry.user?.id ?? '-'),
94
+ : (entry.user?.email ??
95
+ entry.user?.id ??
96
+ '-'),
78
97
  truncate(entry.message, 48),
79
98
  entry.id ?? '-'
80
99
  ]);
@@ -22,15 +22,23 @@ const term = (field, value) => {
22
22
  }
23
23
  return `${field}:${value}`;
24
24
  };
25
- // Validate a --since/--until value: unix-millis or YYYY-MM-DD. ISO timestamps
26
- // carry ':' which FaableQL rejects, so we fail fast with the fix.
27
- const time_term = (field, value) => {
28
- if (value === undefined || value === '')
29
- return undefined;
30
- if (!/^\d+$/.test(value) && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
31
- throw new Error(`Invalid --${field}: "${value}". Use unix-millis or YYYY-MM-DD (ISO timestamps with ':' are not supported)`);
25
+ // A point in time for a range flag: a relative age (`30m`, `24h`, `7d` — that
26
+ // long ago), unix-millis, or YYYY-MM-DD. Returned as what FaableQL accepts:
27
+ // unix-millis or the date as given.
28
+ const time_value = (flag, value, now = Date.now()) => {
29
+ const rel = /^(\d+)([mhd])$/.exec(value);
30
+ if (rel) {
31
+ const unit = { m: 60_000, h: 3_600_000, d: 86_400_000 }[rel[2]];
32
+ return String(now - Number(rel[1]) * unit);
32
33
  }
33
- return `${field}:${value}`;
34
+ if (/^\d+$/.test(value) || /^\d{4}-\d{2}-\d{2}$/.test(value))
35
+ return value;
36
+ throw new Error(`Invalid --${flag}: "${value}". Use a relative age (30m, 24h, 7d), unix-millis or YYYY-MM-DD`);
34
37
  };
38
+ // `<field>:<time>` for a range field of the server (`last_login_since`,
39
+ // `since`…), from a flag that takes a time_value.
40
+ const range_term = (field, flag, value, now) => value === undefined || value === ''
41
+ ? undefined
42
+ : `${field}:${time_value(flag, value, now)}`;
35
43
 
36
- export { compose_query, term, time_term };
44
+ export { compose_query, range_term, term, time_value };
@@ -0,0 +1,15 @@
1
+ import { sessions_list } from './list.js';
2
+ import { sessions_revoke } from './revoke.js';
3
+
4
+ const sessions = {
5
+ command: 'sessions',
6
+ describe: "List and revoke users' sessions (signed-in devices)",
7
+ builder: yargs => yargs
8
+ .command(sessions_list)
9
+ .command(sessions_revoke)
10
+ .demandCommand(1)
11
+ .showHelpOnFail(false),
12
+ handler: () => { }
13
+ };
14
+
15
+ export { sessions };
@@ -0,0 +1,73 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { list_options, fetch_page, print_json, table_lines, print, more_hint } from '../../../lib/listing.js';
4
+ import { tenant_options } from '../options.js';
5
+ import { compose_query, term, range_term } from '../query.js';
6
+ import { truncate, when } from '../render.js';
7
+ import { resolve_user_ref } from '../users/resolve.js';
8
+
9
+ // One row per authentication of a user (arch/auth/user-sessions-devices.md):
10
+ // the devices a user is signed in on, and from where. Not in the generated
11
+ // auth-sdk yet — called through its fetcher, as the dashboard does.
12
+ const sessions_list = {
13
+ command: 'list',
14
+ describe: "List sessions (a user's signed-in devices)",
15
+ builder: yargs => list_options(tenant_options(yargs))
16
+ .option('user', {
17
+ type: 'string',
18
+ description: 'Only this user: id (user_…) or email'
19
+ })
20
+ .option('active', {
21
+ type: 'boolean',
22
+ description: 'Only sessions still active (not revoked)'
23
+ })
24
+ .option('since', {
25
+ type: 'string',
26
+ description: 'Seen at or after: a relative age (30m, 24h, 7d), unix-millis or YYYY-MM-DD'
27
+ })
28
+ .option('query', {
29
+ type: 'string',
30
+ description: 'Raw FaableQL filter (fields: user, client, connection, status, sid, since, until)'
31
+ })
32
+ .example('$0 auth sessions list --user ana@example.com --active', "Ana's devices")
33
+ .showHelpOnFail(false),
34
+ handler: withAuthHints(async (args) => {
35
+ const api = await requireAuthAdmin(args);
36
+ const user = args.user ? await resolve_user_ref(api, args.user) : undefined;
37
+ const query = compose_query([
38
+ term('user', user),
39
+ args.active !== undefined && `status:${args.active ? 'active' : 'revoked'}`,
40
+ range_term('since', 'since', args.since)
41
+ ], args.query);
42
+ const page = await fetch_page(({ pageSize, next }) => api.fetcher.get('/session', {
43
+ params: {
44
+ pageSize: String(pageSize),
45
+ ...(next ? { next } : {}),
46
+ ...(query ? { query } : {})
47
+ }
48
+ }), args);
49
+ if (args.json)
50
+ return print_json(page);
51
+ if (page.data.length === 0) {
52
+ log.info('📭 No sessions match.');
53
+ return;
54
+ }
55
+ log.info(`💻 ${page.data.length} session(s):`);
56
+ const rows = page.data.map((s) => [
57
+ s.id ?? '-',
58
+ typeof s.user === 'string' ? s.user : (s.user?.id ?? '-'),
59
+ s.status ?? '-',
60
+ s.ip ?? '-',
61
+ truncate(s.device_name ?? s.user_agent, 32),
62
+ when(s.last_seen_at)
63
+ ]);
64
+ for (const line of table_lines(['ID', 'USER', 'STATUS', 'IP', 'DEVICE', 'LAST SEEN'], rows)) {
65
+ print(line);
66
+ }
67
+ const hint = more_hint(page);
68
+ if (hint)
69
+ log.info(hint);
70
+ })
71
+ };
72
+
73
+ export { sessions_list };
@@ -0,0 +1,96 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { CliError } from '../../../lib/errors.js';
3
+ import { confirm } from '../../../lib/interactive.js';
4
+ import { json_option, fetch_page, print_noop, print_json } from '../../../lib/listing.js';
5
+ import { log } from '../../../log.js';
6
+ import { tenant_options } from '../options.js';
7
+ import { resolve_user_ref } from '../users/resolve.js';
8
+
9
+ // Ends sessions: the cookie and every refresh token issued through each one
10
+ // stop working. Access tokens already issued live until they expire. Either
11
+ // explicit ids, or --user for every ACTIVE session of one user ("sign them out
12
+ // everywhere").
13
+ const sessions_revoke = {
14
+ command: 'revoke [session_ids..]',
15
+ describe: 'Revoke sessions (sign a user out of their devices)',
16
+ builder: yargs => json_option(tenant_options(yargs))
17
+ .positional('session_ids', {
18
+ type: 'string',
19
+ array: true,
20
+ description: 'Session ids (session_…)'
21
+ })
22
+ .option('user', {
23
+ type: 'string',
24
+ description: 'Every active session of this user: id (user_…) or email'
25
+ })
26
+ .option('yes', {
27
+ alias: 'y',
28
+ type: 'boolean',
29
+ default: false,
30
+ description: 'Skip the confirmation prompt'
31
+ })
32
+ .example('$0 auth sessions revoke --user ana@example.com -y', 'Sign Ana out everywhere')
33
+ .showHelpOnFail(false),
34
+ handler: withAuthHints(async (args) => {
35
+ const given = args.session_ids ?? [];
36
+ if (given.length > 0 === !!args.user) {
37
+ throw new CliError('usage', 'Pass session ids, or --user — one of the two.');
38
+ }
39
+ // Explicit ids: the prompt comes before any request. --user has to look
40
+ // the sessions up first (reads only), so that what is confirmed is what
41
+ // runs.
42
+ let api = args.user ? await requireAuthAdmin(args) : undefined;
43
+ let ids = given;
44
+ let who = '';
45
+ if (args.user && api) {
46
+ const user = await resolve_user_ref(api, args.user);
47
+ who = ` of ${args.user}`;
48
+ const page = await fetch_page(({ pageSize, next }) => api.fetcher.get('/session', {
49
+ params: {
50
+ pageSize: String(pageSize),
51
+ ...(next ? { next } : {}),
52
+ query: `user:${user} status:active`
53
+ }
54
+ }), { limit: 200, all: true });
55
+ ids = page.data.map(s => s.id);
56
+ if (ids.length === 0) {
57
+ return print_noop(`${args.user} has no active sessions`);
58
+ }
59
+ }
60
+ const go = await confirm({
61
+ message: `Revoke ${ids.length} session(s)${who}?`,
62
+ yes: args.yes
63
+ });
64
+ if (!go) {
65
+ log.info('Cancelled.');
66
+ return;
67
+ }
68
+ api ??= await requireAuthAdmin(args);
69
+ const results = [];
70
+ for (const id of ids) {
71
+ try {
72
+ await api.fetcher.post(`/session/${id}/revoke`, {});
73
+ results.push({ id, revoked: true });
74
+ if (!args.json)
75
+ log.info(`🔒 Revoked ${id}`);
76
+ }
77
+ catch (e) {
78
+ const message = e instanceof Error ? e.message : String(e);
79
+ results.push({ id, revoked: false, error: message });
80
+ if (!args.json)
81
+ log.error(`❌ Failed to revoke ${id}: ${message}`);
82
+ }
83
+ }
84
+ if (args.json)
85
+ print_json(results);
86
+ const failed = results.filter(r => r.error);
87
+ if (failed.length > 0) {
88
+ throw new Error(`${failed.length} of ${ids.length} revocation(s) failed`);
89
+ }
90
+ if (!args.json) {
91
+ log.info(`✅ ${ids.length} session(s) revoked. Access tokens already issued stay valid until they expire.`);
92
+ }
93
+ })
94
+ };
95
+
96
+ export { sessions_revoke };
@@ -3,6 +3,7 @@ import { log } from '../../../log.js';
3
3
  import { tenant_options } from '../options.js';
4
4
  import { json_option, fetch_page, from_paginator, print_json } from '../../../lib/listing.js';
5
5
  import { yes_no, when } from '../render.js';
6
+ import { resolve_user_ref } from './resolve.js';
6
7
 
7
8
  // Identity rows carry the RAW provider access/refresh tokens — strip them
8
9
  // before anything (json output included) can print one.
@@ -12,23 +13,24 @@ const sanitize_identity = (i) => {
12
13
  };
13
14
  const users_get = {
14
15
  command: 'get <user_id>',
15
- describe: 'Show a user, including their federated identities',
16
+ describe: 'Show a user (by id or email), including their federated identities',
16
17
  builder: yargs => json_option(tenant_options(yargs))
17
18
  .positional('user_id', {
18
19
  type: 'string',
19
20
  demandOption: true,
20
- description: 'User identifier (user_…)'
21
+ description: 'User id (user_…) or email'
21
22
  })
22
23
  .showHelpOnFail(false),
23
24
  handler: withAuthHints(async (args) => {
24
25
  const api = await requireAuthAdmin(args);
25
- const user = await api.userGet(args.user_id);
26
+ const user_id = await resolve_user_ref(api, args.user_id);
27
+ const user = await api.userGet(user_id);
26
28
  // Federated identities (GitHub, Google…): who this user IS at the
27
29
  // provider — the piece the abuse playbook keeps needing (a Faable email
28
30
  // rarely matches the GitHub handle that owns the deployed repos).
29
31
  // Best-effort: a tenant where the session can't read /identity still
30
32
  // renders the user card.
31
- const identities = await fetch_page(from_paginator(api.identityList({ query: `user:${args.user_id}` })), { limit: 200, all: true })
33
+ const identities = await fetch_page(from_paginator(api.identityList({ query: `user:${user_id}` })), { limit: 200, all: true })
32
34
  .then(r => r.data.map(i => sanitize_identity(i)))
33
35
  .catch(() => []);
34
36
  // Resolve connection ids to names ("github") once per distinct id.
@@ -28,9 +28,11 @@ const parse_user_ids = (args, stdin) => {
28
28
  if (ids.length === 0) {
29
29
  throw new Error('No user ids given (pass ids, or pipe them via stdin)');
30
30
  }
31
- const invalid = ids.filter(id => !/^user_[a-zA-Z0-9]+$/.test(id));
31
+ // An email is resolved to its one user later (resolve_user_refs), before
32
+ // any mutation runs.
33
+ const invalid = ids.filter(id => !/^user_[a-zA-Z0-9]+$/.test(id) && !/^[^\s@]+@[^\s@]+$/.test(id));
32
34
  if (invalid.length > 0) {
33
- throw new Error(`Invalid user id(s): ${invalid.join(', ')} (expected user_…)`);
35
+ throw new Error(`Invalid user id(s): ${invalid.join(', ')} (expected user_… or an email)`);
34
36
  }
35
37
  return ids;
36
38
  };
@@ -2,17 +2,19 @@ import { users_export } from './export.js';
2
2
  import { users_get } from './get.js';
3
3
  import { users_import } from './import.js';
4
4
  import { users_list } from './list.js';
5
+ import { users_password_setup } from './password_setup.js';
5
6
  import { users_reinstate } from './reinstate.js';
6
7
  import { users_suspend } from './suspend.js';
7
8
 
8
9
  const users = {
9
10
  command: 'users',
10
- describe: 'List, inspect, suspend, reinstate, import and export users',
11
+ describe: 'List, count, inspect, suspend, reinstate, import and export users',
11
12
  builder: yargs => yargs
12
13
  .command(users_list)
13
14
  .command(users_get)
14
15
  .command(users_suspend)
15
16
  .command(users_reinstate)
17
+ .command(users_password_setup)
16
18
  .command(users_import)
17
19
  .command(users_export)
18
20
  .demandCommand(1)