@faable/faable 4.5.5 → 4.7.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.
- package/dist/api/FaableApi.js +6 -0
- package/dist/api/auth_admin.js +26 -1
- package/dist/commands/auth/accounts/index.js +11 -4
- package/dist/commands/auth/connections/index.js +10 -0
- package/dist/commands/auth/connections/list.js +48 -0
- package/dist/commands/auth/index.js +5 -1
- package/dist/commands/auth/logs/list.js +28 -9
- package/dist/commands/auth/query.js +17 -9
- package/dist/commands/auth/sessions/index.js +15 -0
- package/dist/commands/auth/sessions/list.js +73 -0
- package/dist/commands/auth/sessions/revoke.js +96 -0
- package/dist/commands/auth/users/get.js +6 -4
- package/dist/commands/auth/users/ids.js +4 -2
- package/dist/commands/auth/users/index.js +3 -1
- package/dist/commands/auth/users/list.js +89 -5
- package/dist/commands/auth/users/password_setup.js +54 -0
- package/dist/commands/auth/users/reinstate.js +8 -3
- package/dist/commands/auth/users/resolve.js +36 -0
- package/dist/commands/auth/users/suspend.js +8 -3
- package/dist/commands/deploy/apps/create.js +35 -2
- package/dist/commands/deploy/github/index.js +10 -0
- package/dist/commands/deploy/github/repos.js +70 -0
- package/dist/commands/deploy/index.js +2 -0
- package/dist/commands/deploy/inspect/inspect.js +18 -1
- package/dist/commands/deploy/inspect/trigger.js +28 -0
- package/dist/commands/deploy/launch.js +62 -2
- package/dist/commands/deploy/wait.js +54 -0
- package/dist/mcp/http.js +117 -23
- package/dist/mcp/oauth.js +165 -0
- package/dist/mcp/server.js +16 -5
- package/dist/mcp/tools.js +427 -6
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -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.
|
package/dist/api/auth_admin.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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,
|
|
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
|
|
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
|
|
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',
|
|
71
|
+
term('user', user),
|
|
58
72
|
term('client', args.client),
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
range_term('since', 'since', args.since),
|
|
74
|
+
range_term('until', 'until', args.until)
|
|
61
75
|
], args.query);
|
|
62
|
-
const
|
|
63
|
-
|
|
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?.
|
|
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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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:${
|
|
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
|
-
|
|
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)
|