@faable/faable 4.19.0 → 4.21.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.
@@ -290,6 +290,11 @@ class FaableApi {
290
290
  headers: projectHeader(team)
291
291
  }));
292
292
  }
293
+ // Native GitLab (api §393): link a gitlab.com project; the api creates the
294
+ // push webhook with the caller's GitLab token.
295
+ async linkGitlab(app_id, params, team) {
296
+ return data(this.client.post(`/app/${app_id}/link-gitlab`, params, { headers: projectHeader(team) }));
297
+ }
293
298
  // Organizations/accounts where the Faable GitHub App is installed.
294
299
  async listGithubInstallations() {
295
300
  return data(this.client.get(`/github/installations`)).then(res => res.installations);
@@ -0,0 +1,83 @@
1
+ // A client's own `login_methods` overrides the tenant's FIELD BY FIELD
2
+ // (auth: resolveLoginMethodsConfig): a field the client does not set is
3
+ // inherited. The update route stores the object whole, so every change here is
4
+ // a read-modify-write of what the client has now, and a field this command was
5
+ // not asked about keeps whatever it was — its own value or the inheritance.
6
+ const has_login_method_changes = (c) => !!(c.inherit ||
7
+ c.passkey ||
8
+ c.only?.length ||
9
+ c.disable?.length ||
10
+ c.enable?.length);
11
+ /**
12
+ * A connection by id or by name (case-insensitive). A name two connections
13
+ * share is refused rather than guessed: removing the wrong login method is
14
+ * not something to find out on the login screen.
15
+ */
16
+ const resolve_connection = (connections, ref) => {
17
+ const by_id = connections.find(c => c.id === ref);
18
+ if (by_id)
19
+ return by_id;
20
+ const by_name = connections.filter(c => c.connection_name?.toLowerCase() === ref.toLowerCase());
21
+ if (by_name.length === 1)
22
+ return by_name[0];
23
+ const known = connections
24
+ .map(c => `${c.connection_name ?? '?'} (${c.id})`)
25
+ .join(', ');
26
+ if (by_name.length > 1) {
27
+ throw new Error(`"${ref}" names ${by_name.length} connections — use the id: ${by_name.map(c => c.id).join(', ')}`);
28
+ }
29
+ throw new Error(`Unknown connection "${ref}". This tenant has: ${known}`);
30
+ };
31
+ /**
32
+ * The client's next `login_methods`, or `null` to inherit everything from the
33
+ * tenant. `undefined` when nothing was asked for.
34
+ */
35
+ const apply_login_method_changes = (own, tenant, connections, changes) => {
36
+ if (!has_login_method_changes(changes))
37
+ return undefined;
38
+ if (changes.inherit)
39
+ return null;
40
+ const next = { ...(own ?? {}) };
41
+ if (changes.only?.length || changes.disable?.length || changes.enable?.length) {
42
+ // Starts from what the client shows TODAY: its own list, or the tenant's
43
+ // it inherits — otherwise the first own list would silently re-offer
44
+ // everything the tenant had removed.
45
+ const disabled = new Set(own?.disabled_connections ?? tenant?.disabled_connections ?? []);
46
+ if (changes.only?.length) {
47
+ const keep = new Set(changes.only.map(ref => resolve_connection(connections, ref).id));
48
+ disabled.clear();
49
+ for (const c of connections)
50
+ if (!keep.has(c.id))
51
+ disabled.add(c.id);
52
+ }
53
+ for (const ref of changes.disable ?? []) {
54
+ disabled.add(resolve_connection(connections, ref).id);
55
+ }
56
+ for (const ref of changes.enable ?? []) {
57
+ disabled.delete(resolve_connection(connections, ref).id);
58
+ }
59
+ next.disabled_connections = [...disabled];
60
+ }
61
+ if (changes.passkey === 'on')
62
+ next.passkey_login_enabled = true;
63
+ else if (changes.passkey === 'off')
64
+ next.passkey_login_enabled = false;
65
+ else if (changes.passkey === 'inherit')
66
+ delete next.passkey_login_enabled;
67
+ return Object.keys(next).length ? next : null;
68
+ };
69
+ /** What the login screen of this client offers, for the summary line. */
70
+ const effective_login_methods = (own, tenant, connections) => {
71
+ const disabled = new Set(own?.disabled_connections ?? tenant?.disabled_connections ?? []);
72
+ const order = new Set(own?.order ?? tenant?.order ?? []);
73
+ const passkey = own?.passkey_login_enabled ?? tenant?.passkey_login_enabled ?? false;
74
+ return {
75
+ passkey,
76
+ // OIDC providers stay off the screen unless `order` names them.
77
+ connections: connections.filter(c => c.enabled !== false &&
78
+ !disabled.has(c.id) &&
79
+ (c.connection_type !== 'oidc' || order.has(c.id)))
80
+ };
81
+ };
82
+
83
+ export { apply_login_method_changes, effective_login_methods, has_login_method_changes, resolve_connection };
@@ -3,6 +3,7 @@ import { log } from '../../../log.js';
3
3
  import { tenant_options } from '../options.js';
4
4
  import { json_option, print_json } from '../../../lib/listing.js';
5
5
  import { parse_field_spec, parse_fields_json, apply_field_changes, format_field } from './profile_fields.js';
6
+ import { has_login_method_changes, apply_login_method_changes, effective_login_methods } from './login_methods.js';
6
7
  import { resolve_client } from './resolve.js';
7
8
 
8
9
  // The update route replaces each list whole, so every list change here is a
@@ -19,7 +20,7 @@ const edit_list = (current, add = [], remove = []) => {
19
20
  };
20
21
  const clients_update = {
21
22
  command: 'update <client_id>',
22
- describe: 'Update an OAuth client (name, URLs, profile fields)',
23
+ describe: 'Update an OAuth client (name, URLs, profile fields, login methods)',
23
24
  builder: yargs => json_option(tenant_options(yargs))
24
25
  .positional('client_id', {
25
26
  type: 'string',
@@ -83,9 +84,40 @@ const clients_update = {
83
84
  .option('inherit-fields', {
84
85
  type: 'boolean',
85
86
  description: "Drop this client's own profile field list and use the tenant's again"
87
+ })
88
+ .option('only-connections', {
89
+ type: 'string',
90
+ array: true,
91
+ description: 'Offer exactly these connections (id or name) on this client\'s login screen; every other one is removed'
92
+ })
93
+ .option('disable-connection', {
94
+ type: 'string',
95
+ array: true,
96
+ description: 'Stop offering a connection (id or name) on this client, also at /authorize — repeatable'
97
+ })
98
+ .option('enable-connection', {
99
+ type: 'string',
100
+ array: true,
101
+ description: 'Offer a removed connection again — repeatable'
102
+ })
103
+ .option('passkey-login', {
104
+ choices: ['on', 'off', 'inherit'],
105
+ description: "Passkey sign-in button on this client's login screen; inherit follows the tenant (Pro plan)"
106
+ })
107
+ .option('inherit-login-methods', {
108
+ type: 'boolean',
109
+ description: "Drop this client's own login methods and follow the tenant's again"
86
110
  })
87
111
  .conflicts('fields-json', ['add-field', 'remove-field', 'inherit-fields'])
88
112
  .conflicts('inherit-fields', ['add-field', 'remove-field'])
113
+ .conflicts('inherit-login-methods', [
114
+ 'only-connections',
115
+ 'disable-connection',
116
+ 'enable-connection',
117
+ 'passkey-login'
118
+ ])
119
+ .conflicts('only-connections', 'disable-connection')
120
+ .example('$0 auth clients update my-admin --only-connections OTP --passkey-login on', 'Sign in to my-admin only with a passkey or an email code')
89
121
  .example('$0 auth clients update my-app --add-field profile:enum:student,teacher', 'Let users of my-app pick their profile with auth-js updateProfile')
90
122
  .example('$0 auth clients update my-app --remove-field school', 'Stop accepting a field')
91
123
  .example('$0 auth clients update my-app --add-callback https://app.example.com/cb', 'Allow one more redirect URL, keeping the others')
@@ -110,6 +142,28 @@ const clients_update = {
110
142
  const callbacks = edit_list(client.callbacks, args.addCallback, args.removeCallback);
111
143
  const logout_urls = edit_list(client.logout_urls, args.addLogoutUrl, args.removeLogoutUrl);
112
144
  const web_origins = edit_list(client.web_origins, args.addWebOrigin, args.removeWebOrigin);
145
+ const login_changes = {
146
+ only: args.onlyConnections,
147
+ disable: args.disableConnection,
148
+ enable: args.enableConnection,
149
+ passkey: args.passkeyLogin,
150
+ inherit: args.inheritLoginMethods
151
+ };
152
+ const own_login = client
153
+ .login_methods;
154
+ let tenant_login;
155
+ let connections = [];
156
+ let login_methods;
157
+ if (has_login_method_changes(login_changes)) {
158
+ const [account, all] = await Promise.all([
159
+ api.currentAccount(),
160
+ api.connectionList().all()
161
+ ]);
162
+ tenant_login = account
163
+ .login_methods;
164
+ connections = all;
165
+ login_methods = apply_login_method_changes(own_login, tenant_login, connections, login_changes);
166
+ }
113
167
  const patch = {
114
168
  ...(args.name !== undefined ? { name: args.name } : {}),
115
169
  ...(args.description !== undefined
@@ -118,15 +172,27 @@ const clients_update = {
118
172
  ...(callbacks ? { callbacks } : {}),
119
173
  ...(logout_urls ? { logout_urls } : {}),
120
174
  ...(web_origins ? { web_origins } : {}),
121
- ...(profile_fields !== undefined ? { profile_fields } : {})
175
+ ...(profile_fields !== undefined ? { profile_fields } : {}),
176
+ ...(login_methods !== undefined ? { login_methods } : {})
122
177
  };
123
178
  if (Object.keys(patch).length === 0) {
124
- throw new Error('Nothing to update — pass a --name, --description, an --add-*/--remove-* option, --fields-json or --inherit-fields');
179
+ throw new Error('Nothing to update — pass a --name, --description, an --add-*/--remove-* option, --fields-json, --inherit-fields or a login-method option');
125
180
  }
126
181
  const updated = await api.clientUpdate(client.id, patch);
127
182
  if (args.json)
128
183
  return print_json(updated);
129
184
  log.info(`✅ Updated client "${updated.name}" (${updated.client_id})`);
185
+ if (login_methods !== undefined) {
186
+ const now = effective_login_methods(updated
187
+ .login_methods, tenant_login, connections);
188
+ const names = now.connections.map(c => `${c.connection_name} (${c.connection_type})`);
189
+ if (now.passkey)
190
+ names.unshift('passkey');
191
+ log.info(` Login screen: ${names.length ? names.join(', ') : 'nothing'}`);
192
+ if (login_methods === null) {
193
+ log.info(" Login methods: inherited from the tenant");
194
+ }
195
+ }
130
196
  if (profile_fields !== undefined) {
131
197
  const now = updated.profile_fields;
132
198
  if (!now) {
@@ -28,6 +28,10 @@ const link = {
28
28
  alias: "r",
29
29
  type: "string",
30
30
  description: "GitHub repository, owner/repo or its URL (defaults to the origin remote of --workdir)",
31
+ })
32
+ .option("gitlab", {
33
+ type: "string",
34
+ description: "Link a gitlab.com project instead: its Project ID (the number on its page). Needs --app and GitLab connected in the dashboard",
31
35
  })
32
36
  .option("branch", {
33
37
  alias: "b",
@@ -40,9 +44,12 @@ const link = {
40
44
  description: "Print the link as JSON: app, repository, branch and how it deploys",
41
45
  })
42
46
  .example("$0 deploy link --app web --repo acme/web", "Link acme/web to the app web: from now on a push deploys it")
47
+ .example("$0 deploy link --app web --gitlab 12345678", "Link that gitlab.com project to the app web: pushes deploy it")
43
48
  .showHelpOnFail(false);
44
49
  },
45
50
  handler: async (args) => {
51
+ if (args.gitlab !== undefined)
52
+ return link_gitlab(args);
46
53
  if (args.app || args.json || is_non_interactive()) {
47
54
  return link_to_app(args);
48
55
  }
@@ -147,6 +154,39 @@ const link = {
147
154
  // repository to an existing app at all (arch/deploy/cli-mcp-fallos-2026-10-16-plan.md,
148
155
  // F4; backlog §366). Driven by a program, the repository is spelled out too:
149
156
  // the origin remote of whatever directory the process runs in is a guess.
157
+ const link_gitlab = async (args) => {
158
+ if (!args.app) {
159
+ throw new CliError("app_required", "Say which app to link: --app <id|name>.");
160
+ }
161
+ const project_id = String(args.gitlab ?? "");
162
+ if (!/^[0-9]+$/.test(project_id)) {
163
+ throw new CliError("usage", "--gitlab takes the numeric Project ID shown on the GitLab project page, not its path.");
164
+ }
165
+ const { api } = await requireApi();
166
+ const app = await api.getApp(await resolve_app_ref(api, args.app));
167
+ let linked;
168
+ try {
169
+ linked = await api.linkGitlab(app.id, { project_id, ...(args.branch ? { branch: args.branch } : {}) }, app.team);
170
+ }
171
+ catch (err) {
172
+ const e = err;
173
+ const failed = new CliError("usage", `Could not link GitLab project ${project_id} to ${app.name}: ${e.message}`, { status: e.status, action: e.action, cause: err });
174
+ // gitlab_identity_missing / gitlab_token_invalid / gitlab_project_not_accessible
175
+ if (e.code)
176
+ failed.code = e.code;
177
+ throw failed;
178
+ }
179
+ const result = {
180
+ app_id: app.id,
181
+ app_name: app.name,
182
+ gitlab_project: linked?.gitlab_project_path ?? project_id,
183
+ branch: linked?.gitlab_branch,
184
+ };
185
+ if (args.json)
186
+ return print_json(result);
187
+ log.info(`Linked ${result.gitlab_project} (GitLab) to ${app.name} (${app.id}).`);
188
+ log.info(`Push to deploy is on: the next push to "${result.branch ?? "the default branch"}" deploys.`);
189
+ };
150
190
  const link_to_app = async (args) => {
151
191
  if (!args.app) {
152
192
  throw new CliError("app_required", "Say which app to link: --app <id|name>. Without a terminal there is no list to pick from.");
package/dist/mcp/tools.js CHANGED
@@ -998,6 +998,64 @@ const TOOLS = [
998
998
  remove: ['school']
999
999
  }
1000
1000
  },
1001
+ {
1002
+ name: 'update_auth_client_login',
1003
+ title: "Change how users sign in to an app",
1004
+ description: "Choose which login methods one Auth application offers: which connections (by id or name, as list_auth_connections shows them) and whether the passkey button appears. `only` keeps exactly those connections and removes the rest; `disable`/`enable` change one at a time. A removed connection is refused at /authorize too, not just hidden. `passkey` needs the Pro plan. `inherit: true` drops the app's own settings and follows the tenant's again. Settings not mentioned keep their current value.",
1005
+ input: {
1006
+ client: z
1007
+ .string()
1008
+ .describe('The application: client resource id (client_…) or OAuth client_id'),
1009
+ only: z
1010
+ .array(z.string())
1011
+ .max(50)
1012
+ .optional()
1013
+ .describe('Offer exactly these connections (id or name)'),
1014
+ disable: z
1015
+ .array(z.string())
1016
+ .max(50)
1017
+ .optional()
1018
+ .describe('Connections (id or name) to stop offering'),
1019
+ enable: z
1020
+ .array(z.string())
1021
+ .max(50)
1022
+ .optional()
1023
+ .describe('Removed connections (id or name) to offer again'),
1024
+ passkey: z
1025
+ .enum(['on', 'off', 'inherit'])
1026
+ .optional()
1027
+ .describe('Passkey sign-in button; inherit follows the tenant'),
1028
+ inherit: z
1029
+ .boolean()
1030
+ .optional()
1031
+ .describe("Drop the app's own login methods and use the tenant's"),
1032
+ tenant,
1033
+ project
1034
+ },
1035
+ command: ['auth', 'clients', 'update'],
1036
+ flags: a => [
1037
+ a.client,
1038
+ ...(a.only ?? []).flatMap((c) => ['--only-connections', c]),
1039
+ ...(a.disable ?? []).flatMap((c) => ['--disable-connection', c]),
1040
+ ...(a.enable ?? []).flatMap((c) => ['--enable-connection', c]),
1041
+ ...opt('--passkey-login', a.passkey),
1042
+ ...(a.inherit ? ['--inherit-login-methods'] : []),
1043
+ ...auth_scope(a)
1044
+ ],
1045
+ shape: (c) => ({
1046
+ id: c.id,
1047
+ client_id: c.client_id,
1048
+ name: c.name,
1049
+ login_methods: c.login_methods ?? null
1050
+ }),
1051
+ write: true,
1052
+ annotations: AUTH_WRITE,
1053
+ example: {
1054
+ client: 'client_1',
1055
+ only: ['OTP'],
1056
+ passkey: 'on'
1057
+ }
1058
+ },
1001
1059
  {
1002
1060
  name: 'send_password_setup',
1003
1061
  title: 'Send a password setup email',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.19.0",
3
+ "version": "4.21.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",