@faable/faable 4.7.0 → 4.9.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.
@@ -2,6 +2,7 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  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
+ import { format_field } from './profile_fields.js';
5
6
  import { resolve_client } from './resolve.js';
6
7
 
7
8
  const clients_get = {
@@ -39,6 +40,8 @@ const clients_get = {
39
40
  if (client.web_origins?.length) {
40
41
  log.info(` Web origins: ${client.web_origins.join(', ')}`);
41
42
  }
43
+ const fields = client.profile_fields;
44
+ log.info(` Fields: ${fields ? (fields.length ? fields.map(format_field).join(', ') : 'none') : 'inherited from the tenant'}`);
42
45
  log.info(` Created: ${client.createdAt ?? '-'}`);
43
46
  })
44
47
  };
@@ -2,6 +2,7 @@ import { clients_create } from './create.js';
2
2
  import { clients_get } from './get.js';
3
3
  import { clients_list } from './list.js';
4
4
  import { clients_rm } from './rm.js';
5
+ import { clients_update } from './update.js';
5
6
 
6
7
  const clients = {
7
8
  command: 'clients',
@@ -10,6 +11,7 @@ const clients = {
10
11
  .command(clients_list)
11
12
  .command(clients_get)
12
13
  .command(clients_create)
14
+ .command(clients_update)
13
15
  .command(clients_rm)
14
16
  .demandCommand(1)
15
17
  .showHelpOnFail(false),
@@ -0,0 +1,121 @@
1
+ // The `profile_fields` of a client: which custom fields its users may write on
2
+ // themselves with `PATCH /me` (`auth-js updateProfile`), stored in
3
+ // `user_metadata["faable.com/fields"]`. A client's list REPLACES the tenant's
4
+ // for that app; no list (`null`) inherits the tenant's.
5
+ const KEY = /^[a-z0-9_]{1,40}$/;
6
+ const invalid = (spec, why) => new Error(`Invalid field "${spec}": ${why}`);
7
+ const checkKey = (key, spec) => {
8
+ if (!KEY.test(key)) {
9
+ throw invalid(spec, 'the key takes lowercase letters, digits and _ (1-40 characters)');
10
+ }
11
+ };
12
+ /**
13
+ * `--add-field` syntax:
14
+ *
15
+ * profile a string field, default max length (200)
16
+ * school:string:120 a string field of at most 120 characters
17
+ * profile:enum:student,teacher one of the listed values
18
+ */
19
+ const parse_field_spec = (spec) => {
20
+ const [key, type, rest] = spec.includes(':')
21
+ ? splitSpec(spec)
22
+ : [spec, 'string', undefined];
23
+ checkKey(key, spec);
24
+ if (type === 'enum') {
25
+ const options = (rest ?? '')
26
+ .split(',')
27
+ .map(o => o.trim())
28
+ .filter(Boolean);
29
+ if (!options.length) {
30
+ throw invalid(spec, 'an enum needs its values: key:enum:a,b,c');
31
+ }
32
+ return { key, type: 'enum', options: [...new Set(options)] };
33
+ }
34
+ if (type === 'string') {
35
+ if (rest === undefined || rest === '')
36
+ return { key, type: 'string' };
37
+ const max = Number(rest);
38
+ if (!Number.isInteger(max) || max < 1 || max > 1000) {
39
+ throw invalid(spec, 'the max length is a whole number from 1 to 1000');
40
+ }
41
+ return { key, type: 'string', max_length: max };
42
+ }
43
+ throw invalid(spec, `unknown type "${type}" (string or enum)`);
44
+ };
45
+ // `key:type:rest`, where `rest` may itself contain `:` (an enum value).
46
+ const splitSpec = (spec) => {
47
+ const first = spec.indexOf(':');
48
+ const key = spec.slice(0, first);
49
+ const after = spec.slice(first + 1);
50
+ const second = after.indexOf(':');
51
+ if (second < 0)
52
+ return [key, after, undefined];
53
+ return [key, after.slice(0, second), after.slice(second + 1)];
54
+ };
55
+ /** `--fields-json`: the whole list, validated the same way. */
56
+ const parse_fields_json = (raw) => {
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ }
61
+ catch {
62
+ throw new Error('--fields-json is not valid JSON');
63
+ }
64
+ if (!Array.isArray(parsed)) {
65
+ throw new Error('--fields-json must be a JSON array of field declarations');
66
+ }
67
+ return parsed.map((f, i) => {
68
+ const d = f;
69
+ const spec = `--fields-json[${i}]`;
70
+ if (typeof d?.key !== 'string')
71
+ throw invalid(spec, 'missing "key"');
72
+ checkKey(d.key, spec);
73
+ if (d.type === 'enum') {
74
+ if (!Array.isArray(d.options) || !d.options.length) {
75
+ throw invalid(spec, 'an enum needs "options"');
76
+ }
77
+ return { key: d.key, type: 'enum', options: d.options.map(String) };
78
+ }
79
+ if (d.type !== 'string') {
80
+ throw invalid(spec, '"type" is "string" or "enum"');
81
+ }
82
+ return {
83
+ key: d.key,
84
+ type: 'string',
85
+ ...(d.max_length !== undefined ? { max_length: d.max_length } : {})
86
+ };
87
+ });
88
+ };
89
+ /**
90
+ * The client's new list after removing and adding. Adding a key that is
91
+ * already declared replaces its declaration in place; removing a key that is
92
+ * not there is an error, so a typo does not look like success.
93
+ */
94
+ const apply_field_changes = (current, adds, removes) => {
95
+ const next = [...(current ?? [])];
96
+ for (const key of removes) {
97
+ const at = next.findIndex(f => f.key === key);
98
+ if (at < 0) {
99
+ const have = next.map(f => f.key).join(', ') || 'none';
100
+ throw new Error(`The client has no field "${key}" (declared: ${have})`);
101
+ }
102
+ next.splice(at, 1);
103
+ }
104
+ for (const field of adds) {
105
+ const at = next.findIndex(f => f.key === field.key);
106
+ if (at >= 0)
107
+ next[at] = field;
108
+ else
109
+ next.push(field);
110
+ }
111
+ if (next.length > 20) {
112
+ throw new Error(`A client declares at most 20 fields (this would be ${next.length})`);
113
+ }
114
+ return next;
115
+ };
116
+ /** One line per field, for `clients get` and the update summary. */
117
+ const format_field = (f) => f.type === 'enum'
118
+ ? `${f.key} (enum: ${(f.options ?? []).join(', ')})`
119
+ : `${f.key} (string${f.max_length ? `, max ${f.max_length}` : ''})`;
120
+
121
+ export { apply_field_changes, format_field, parse_field_spec, parse_fields_json };
@@ -0,0 +1,146 @@
1
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
+ import { log } from '../../../log.js';
3
+ import { tenant_options } from '../options.js';
4
+ import { json_option, print_json } from '../../../lib/listing.js';
5
+ import { parse_field_spec, parse_fields_json, apply_field_changes, format_field } from './profile_fields.js';
6
+ import { resolve_client } from './resolve.js';
7
+
8
+ // The update route replaces each list whole, so every list change here is a
9
+ // read-modify-write of what the client has now: adding a callback never drops
10
+ // the others.
11
+ const edit_list = (current, add = [], remove = []) => {
12
+ if (!add.length && !remove.length)
13
+ return undefined;
14
+ const next = (current ?? []).filter(u => !remove.includes(u));
15
+ for (const u of add)
16
+ if (!next.includes(u))
17
+ next.push(u);
18
+ return next;
19
+ };
20
+ const clients_update = {
21
+ command: 'update <client_id>',
22
+ describe: 'Update an OAuth client (name, URLs, profile fields)',
23
+ builder: yargs => json_option(tenant_options(yargs))
24
+ .positional('client_id', {
25
+ type: 'string',
26
+ demandOption: true,
27
+ description: 'Client resource id or OAuth client_id'
28
+ })
29
+ .option('name', {
30
+ alias: 'n',
31
+ type: 'string',
32
+ description: 'New client name'
33
+ })
34
+ .option('description', {
35
+ alias: 'd',
36
+ type: 'string',
37
+ description: 'New client description'
38
+ })
39
+ .option('add-callback', {
40
+ type: 'string',
41
+ array: true,
42
+ description: 'Add an allowed callback (redirect) URL — repeatable'
43
+ })
44
+ .option('remove-callback', {
45
+ type: 'string',
46
+ array: true,
47
+ description: 'Remove an allowed callback URL — repeatable'
48
+ })
49
+ .option('add-logout-url', {
50
+ type: 'string',
51
+ array: true,
52
+ description: 'Add an allowed post-logout URL — repeatable'
53
+ })
54
+ .option('remove-logout-url', {
55
+ type: 'string',
56
+ array: true,
57
+ description: 'Remove an allowed post-logout URL — repeatable'
58
+ })
59
+ .option('add-web-origin', {
60
+ type: 'string',
61
+ array: true,
62
+ description: 'Add an allowed web origin (CORS) — repeatable'
63
+ })
64
+ .option('remove-web-origin', {
65
+ type: 'string',
66
+ array: true,
67
+ description: 'Remove an allowed web origin — repeatable'
68
+ })
69
+ .option('add-field', {
70
+ type: 'string',
71
+ array: true,
72
+ description: 'Declare a profile field users may write with PATCH /me: key, key:string[:max_length] or key:enum:a,b,c. Re-adding a key replaces it — repeatable'
73
+ })
74
+ .option('remove-field', {
75
+ type: 'string',
76
+ array: true,
77
+ description: 'Remove a declared profile field by key — repeatable'
78
+ })
79
+ .option('fields-json', {
80
+ type: 'string',
81
+ description: 'Replace the whole profile field list with a JSON array of {key, type, options?, max_length?}'
82
+ })
83
+ .option('inherit-fields', {
84
+ type: 'boolean',
85
+ description: "Drop this client's own profile field list and use the tenant's again"
86
+ })
87
+ .conflicts('fields-json', ['add-field', 'remove-field', 'inherit-fields'])
88
+ .conflicts('inherit-fields', ['add-field', 'remove-field'])
89
+ .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
+ .example('$0 auth clients update my-app --remove-field school', 'Stop accepting a field')
91
+ .example('$0 auth clients update my-app --add-callback https://app.example.com/cb', 'Allow one more redirect URL, keeping the others')
92
+ .showHelpOnFail(false),
93
+ handler: withAuthHints(async (args) => {
94
+ // Parse everything before any request, so a bad spec costs nothing.
95
+ const adds = (args.addField ?? []).map(parse_field_spec);
96
+ const full = args.fieldsJson
97
+ ? parse_fields_json(args.fieldsJson)
98
+ : undefined;
99
+ const api = await requireAuthAdmin(args);
100
+ const client = await resolve_client(api, args.client_id);
101
+ const own = client.profile_fields;
102
+ let profile_fields;
103
+ if (full)
104
+ profile_fields = full;
105
+ else if (args.inheritFields)
106
+ profile_fields = null;
107
+ else if (adds.length || args.removeField?.length) {
108
+ profile_fields = apply_field_changes(own, adds, args.removeField ?? []);
109
+ }
110
+ const callbacks = edit_list(client.callbacks, args.addCallback, args.removeCallback);
111
+ const logout_urls = edit_list(client.logout_urls, args.addLogoutUrl, args.removeLogoutUrl);
112
+ const web_origins = edit_list(client.web_origins, args.addWebOrigin, args.removeWebOrigin);
113
+ const patch = {
114
+ ...(args.name !== undefined ? { name: args.name } : {}),
115
+ ...(args.description !== undefined
116
+ ? { description: args.description }
117
+ : {}),
118
+ ...(callbacks ? { callbacks } : {}),
119
+ ...(logout_urls ? { logout_urls } : {}),
120
+ ...(web_origins ? { web_origins } : {}),
121
+ ...(profile_fields !== undefined ? { profile_fields } : {})
122
+ };
123
+ 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');
125
+ }
126
+ const updated = await api.clientUpdate(client.id, patch);
127
+ if (args.json)
128
+ return print_json(updated);
129
+ log.info(`✅ Updated client "${updated.name}" (${updated.client_id})`);
130
+ if (profile_fields !== undefined) {
131
+ const now = updated.profile_fields;
132
+ if (!now) {
133
+ log.info(' Profile fields: inherited from the tenant');
134
+ }
135
+ else {
136
+ log.info(` Profile fields: ${now.length ? now.map(format_field).join(', ') : 'none'}`);
137
+ // The first own list silently stops inheriting the tenant's: say so.
138
+ if (!own) {
139
+ log.info(" ℹ️ This client now has its own list, which replaces the tenant's for it. --inherit-fields undoes that.");
140
+ }
141
+ }
142
+ }
143
+ })
144
+ };
145
+
146
+ export { clients_update };
package/dist/mcp/http.js CHANGED
@@ -186,7 +186,7 @@ const llms_txt = (base = PUBLIC_URL) => [
186
186
  '> Connects Claude, Cursor and any MCP client to Faable Deploy and Faable Auth: read apps, deployments, build and runtime logs, traffic and domains, and deploy; see who logs in to your apps, count and find users, and suspend them — without leaving the editor.',
187
187
  '',
188
188
  oauth_enabled()
189
- ? `Endpoint: ${base}/mcp (Streamable HTTP). Sign in with OAuth: an MCP client discovers it from the 401 (\`resource_metadata\`), opens a Faable sign-in and consent screen where the user picks one project and what the agent may do (\`deploy:read\`, \`deploy:deploy\`; \`deploy:write\` is asked for the first time a write tool is called). Or send a Faable API key: \`Authorization: Bearer <key>\` (project settings → API keys). Either way it acts on one project only.`
189
+ ? `Endpoint: ${base}/mcp (Streamable HTTP). Sign in with OAuth: an MCP client discovers it from the 401 (\`resource_metadata\`), opens a Faable sign-in and consent screen where the user picks one project and what the agent may do (\`deploy:read\`, \`deploy:deploy\`; \`deploy:write\`, and \`auth:read\` / \`auth:write\` for the Faable Auth tools, are asked for the first time a tool needs them). Or send a Faable API key: \`Authorization: Bearer <key>\` (project settings → API keys). Either way it acts on one project only.`
190
190
  : `Endpoint: ${base}/mcp (Streamable HTTP). Authenticate with a Faable API key: \`Authorization: Bearer <key>\` (create one in the dashboard, project settings → API keys). The key belongs to one project and only acts there.`,
191
191
  '',
192
192
  `- \`${base}/mcp/<project>\` pins every call to one project.`,
package/dist/mcp/oauth.js CHANGED
@@ -26,7 +26,9 @@ const DEFAULT_SCOPES = ['deploy:read', 'deploy:deploy'];
26
26
  const ALL_SCOPES = [
27
27
  'deploy:read',
28
28
  'deploy:deploy',
29
- 'deploy:write'
29
+ 'deploy:write',
30
+ 'auth:read',
31
+ 'auth:write'
30
32
  ];
31
33
  const DEPLOY_TOOLS = new Set(['deploy_app', 'redeploy', 'cancel_deployment']);
32
34
  /** The permission a tool needs — the same split the api enforces. */
package/dist/mcp/tools.js CHANGED
@@ -900,6 +900,53 @@ const TOOLS = [
900
900
  annotations: AUTH_WRITE,
901
901
  example: { user: 'ana@example.com' }
902
902
  },
903
+ {
904
+ name: 'update_auth_client_fields',
905
+ title: "Change an app's profile fields",
906
+ description: "Declare which custom profile fields the users of one Auth application may write on themselves (auth-js `updateProfile`, stored in user_metadata[\"faable.com/fields\"]). `add` takes `key`, `key:string:120` or `key:enum:a,b,c`; re-adding a key replaces it. `inherit: true` drops the app's own list and uses the tenant's again. An app's own list replaces the tenant's for that app. Only the fields: the app's URLs and settings are not changed here.",
907
+ input: {
908
+ client: z
909
+ .string()
910
+ .describe('The application: client resource id (client_…) or OAuth client_id'),
911
+ add: z
912
+ .array(z.string())
913
+ .max(20)
914
+ .optional()
915
+ .describe('Fields to declare: key, key:string[:max_length] or key:enum:a,b,c'),
916
+ remove: z
917
+ .array(z.string())
918
+ .max(20)
919
+ .optional()
920
+ .describe('Field keys to stop accepting'),
921
+ inherit: z
922
+ .boolean()
923
+ .optional()
924
+ .describe("Drop the app's own list and use the tenant's"),
925
+ tenant,
926
+ project
927
+ },
928
+ command: ['auth', 'clients', 'update'],
929
+ flags: a => [
930
+ a.client,
931
+ ...(a.add ?? []).flatMap((f) => ['--add-field', f]),
932
+ ...(a.remove ?? []).flatMap((k) => ['--remove-field', k]),
933
+ ...(a.inherit ? ['--inherit-fields'] : []),
934
+ ...auth_scope(a)
935
+ ],
936
+ shape: (c) => ({
937
+ id: c.id,
938
+ client_id: c.client_id,
939
+ name: c.name,
940
+ profile_fields: c.profile_fields ?? null
941
+ }),
942
+ write: true,
943
+ annotations: AUTH_WRITE,
944
+ example: {
945
+ client: 'client_1',
946
+ add: ['profile:enum:student,teacher'],
947
+ remove: ['school']
948
+ }
949
+ },
903
950
  {
904
951
  name: 'send_password_setup',
905
952
  title: 'Send a password setup email',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "4.7.0",
3
+ "version": "4.9.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@actions/core": "^3.0.0",
30
- "@faable/auth-sdk": "^2.7.29",
30
+ "@faable/auth-sdk": "^2.7.105",
31
31
  "@modelcontextprotocol/sdk": "^1.31.0",
32
32
  "axios": "^1.20.0",
33
33
  "fs-extra": "^11.3.2",