@faable/faable 1.35.0 → 1.37.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.
@@ -1,11 +1,18 @@
1
1
  import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
3
  import { json_option, tenant_options } from '../options.js';
4
+ import { fetch_items } from '../paging.js';
4
5
  import { print_json, yes_no, when } from '../render.js';
5
6
 
7
+ // Identity rows carry the RAW provider access/refresh tokens — strip them
8
+ // before anything (json output included) can print one.
9
+ const sanitize_identity = (i) => {
10
+ const { access_token: _a, access_token_secret: _s, refresh_token: _r, ...safe } = i;
11
+ return safe;
12
+ };
6
13
  const users_get = {
7
14
  command: 'get <user_id>',
8
- describe: 'Show a user',
15
+ describe: 'Show a user, including their federated identities',
9
16
  builder: yargs => json_option(tenant_options(yargs))
10
17
  .positional('user_id', {
11
18
  type: 'string',
@@ -16,8 +23,22 @@ const users_get = {
16
23
  handler: withAuthHints(async (args) => {
17
24
  const api = await requireAuthAdmin(args);
18
25
  const user = await api.userGet(args.user_id);
26
+ // Federated identities (GitHub, Google…): who this user IS at the
27
+ // provider — the piece the abuse playbook keeps needing (a Faable email
28
+ // rarely matches the GitHub handle that owns the deployed repos).
29
+ // Best-effort: a tenant where the session can't read /identity still
30
+ // renders the user card.
31
+ const identities = await fetch_items(api.identityList({ query: `user:${args.user_id}` }), true)
32
+ .then(r => r.items.map(i => sanitize_identity(i)))
33
+ .catch(() => []);
34
+ // Resolve connection ids to names ("github") once per distinct id.
35
+ const connection_names = new Map();
36
+ for (const id of new Set(identities.map(i => String(i.connection ?? '')).filter(Boolean))) {
37
+ const conn = (await api.connectionGet(id).catch(() => null));
38
+ connection_names.set(id, conn?.connection_name ?? conn?.connection_type ?? id);
39
+ }
19
40
  if (args.json)
20
- return print_json(user);
41
+ return print_json({ ...user, identities });
21
42
  log.info(`👤 ${user.id}`);
22
43
  log.info(` Email: ${user.email ?? '-'} (verified: ${yes_no(user.email_verified)})`);
23
44
  log.info(` Name: ${user.name ?? '-'}`);
@@ -36,6 +57,24 @@ const users_get = {
36
57
  else {
37
58
  log.info(' Suspended: no');
38
59
  }
60
+ if (identities.length > 0) {
61
+ log.info(` Identities:`);
62
+ for (const identity of identities) {
63
+ const conn = connection_names.get(String(identity.connection ?? '')) ?? '-';
64
+ const pd = (identity.profile_data ?? {});
65
+ const bits = [
66
+ pd.login ?? String(identity.identity_id ?? '-'),
67
+ pd.html_url,
68
+ // Provider-account age is triage gold: a repo pushed from a
69
+ // years-old GitHub account reads differently than one created
70
+ // this morning.
71
+ pd.created_at ? `account since ${String(pd.created_at).slice(0, 10)}` : undefined,
72
+ pd.email && pd.email !== user.email ? `email ${pd.email}` : undefined,
73
+ pd.location ? `location ${pd.location}` : undefined
74
+ ].filter(Boolean);
75
+ log.info(` ${conn}: ${bits.join(' · ')}`);
76
+ }
77
+ }
39
78
  })
40
79
  };
41
80
 
@@ -1,6 +1,7 @@
1
1
  import { requireApi } from '../../../api/context.js';
2
2
  import { log } from '../../../log.js';
3
3
  import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { managed_name, managed_warning } from './managed_names.js';
4
5
  import { mask_value } from './mask.js';
5
6
 
6
7
  const secrets_list = {
@@ -33,12 +34,28 @@ const secrets_list = {
33
34
  const sorted = [...secrets].sort((a, b) => a.name.localeCompare(b.name));
34
35
  for (const secret of sorted) {
35
36
  const value = args.show ? secret.value : mask_value(secret.value);
36
- const origin = secret.related_model === 'profile' ? ' (inherited from team profile)' : '';
37
- log.info(` ${secret.name.padEnd(width)} ${value}${origin}`);
37
+ const origin = secret.related_model === 'profile'
38
+ ? ' (inherited from team profile)'
39
+ : '';
40
+ // Flag names the platform manages: a stored PORT looks perfectly set
41
+ // here while being dropped at deploy time.
42
+ const managed = managed_name(secret.name);
43
+ const note = !managed
44
+ ? ''
45
+ : managed.kind === 'reserved'
46
+ ? ' ⚠️ reserved, ignored at deploy'
47
+ : ' ⚠️ overrides a platform default';
48
+ log.info(` ${secret.name.padEnd(width)} ${value}${origin}${note}`);
38
49
  }
39
50
  if (!args.show) {
40
51
  log.info(`Use --show to reveal full values.`);
41
52
  }
53
+ // The per-row marker says WHICH; this says why, once, with the docs link.
54
+ for (const secret of sorted) {
55
+ const warning = managed_warning(secret.name);
56
+ if (warning)
57
+ log.warn(`⚠️ ${warning}`);
58
+ }
42
59
  }
43
60
  };
44
61
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Environment variables the platform already provides.
3
+ *
4
+ * Two very different failure modes, so they are warned about differently:
5
+ *
6
+ * - `reserved`: the controller DROPS a user secret with this name before
7
+ * building the Deployment's env list (controller-deploy
8
+ * src/k8s/deployment.actions.ts, RESERVED). Setting it is a silent no-op —
9
+ * the platform value always wins — which is exactly the kind of thing
10
+ * people spend an afternoon debugging.
11
+ * - `runtime_default`: the runtime image already exports it
12
+ * (buildpacks/images/runtime-{node,python}/Dockerfile). A user value DOES
13
+ * win here, so it is legal — just usually redundant, and occasionally the
14
+ * reason a production build behaves like a dev one.
15
+ *
16
+ * Mirrored in the dashboard (dashboard components/apps/deploy/secrets/
17
+ * managed_names.ts) so both surfaces say the same thing. Keep in sync.
18
+ */
19
+ const DOCS_BASE = 'https://faable.com/docs/deploy/environment';
20
+ const MANAGED_DOCS_URL = {
21
+ reserved: `${DOCS_BASE}#reserved-names`,
22
+ runtime_default: `${DOCS_BASE}#runtime-defaults`
23
+ };
24
+ const MANAGED_ENV_NAMES = {
25
+ PORT: {
26
+ kind: 'reserved',
27
+ detail: 'to 80 — the port your app must listen on'
28
+ },
29
+ FAABLE_HOST: { kind: 'reserved', detail: "to your app's public host" },
30
+ FAABLE_APP_ID: { kind: 'reserved', detail: 'to this app id' },
31
+ FAABLE_DEPLOY_ID: { kind: 'reserved', detail: "to each deployment's id" },
32
+ FAABLE_RELEASE: {
33
+ kind: 'reserved',
34
+ detail: 'from --release or your latest git tag'
35
+ },
36
+ FAABLE_GIT_COMMIT: {
37
+ kind: 'reserved',
38
+ detail: 'to the commit the deployment was built from'
39
+ },
40
+ FAABLE_GIT_REF: { kind: 'reserved', detail: 'to the deployed git ref' },
41
+ START_COMMAND: {
42
+ kind: 'reserved',
43
+ detail: "to the artifact's start command"
44
+ },
45
+ NODE_ENV: {
46
+ kind: 'runtime_default',
47
+ detail: 'to "production" on Node runtimes'
48
+ },
49
+ PYTHONUNBUFFERED: {
50
+ kind: 'runtime_default',
51
+ detail: 'to "1" on Python runtimes'
52
+ }
53
+ };
54
+ const managed_name = (name) => MANAGED_ENV_NAMES[name.trim()];
55
+ // One line, ready for a warning log. Undefined when the name is the user's
56
+ // own business.
57
+ const managed_warning = (name) => {
58
+ const managed = managed_name(name);
59
+ if (!managed)
60
+ return undefined;
61
+ const key = name.trim();
62
+ const url = MANAGED_DOCS_URL[managed.kind];
63
+ return managed.kind === 'reserved'
64
+ ? `${key} is reserved: your value is ignored. Faable sets it ${managed.detail}. ${url}`
65
+ : `${key} is already set ${managed.detail}; override it only if you mean to. ${url}`;
66
+ };
67
+
68
+ export { MANAGED_DOCS_URL, MANAGED_ENV_NAMES, managed_name, managed_warning };
@@ -0,0 +1,101 @@
1
+ import { validate_pair } from './parse_pairs.js';
2
+
3
+ /**
4
+ * Parser for `.env` files, the format every runtime already agrees on
5
+ * (dotenv, docker, `source`). Deliberately a strict subset:
6
+ *
7
+ * - `#` comments and blank lines are skipped; an inline `#` only starts a
8
+ * comment when it follows whitespace in an UNQUOTED value.
9
+ * - a leading `export ` is tolerated, so a file meant for `source` works.
10
+ * - values may be single- or double-quoted; double quotes expand `\n`, `\r`,
11
+ * `\t`, `\\` and `\"`, single quotes are literal. Quoted values may span
12
+ * lines, which is how PEM keys survive.
13
+ * - a line that is not a comment and has no `=` is an ERROR, not a silently
14
+ * ignored line: a typo must not quietly drop a variable from the deploy.
15
+ *
16
+ * Repeated names keep the last occurrence, matching `source`.
17
+ */
18
+ // Closing quote for `quote`, honouring backslash escapes inside double
19
+ // quotes. Single quotes have no escapes, so the first one closes.
20
+ const closing_quote = (text, quote) => {
21
+ if (quote === "'")
22
+ return text.indexOf(quote);
23
+ for (let i = 0; i < text.length; i++) {
24
+ if (text[i] === '\\') {
25
+ i++;
26
+ continue;
27
+ }
28
+ if (text[i] === quote)
29
+ return i;
30
+ }
31
+ return -1;
32
+ };
33
+ const ESCAPES = {
34
+ n: '\n',
35
+ r: '\r',
36
+ t: '\t',
37
+ '\\': '\\',
38
+ '"': '"',
39
+ "'": "'"
40
+ };
41
+ const unescape_double = (value) => value.replace(/\\(.)/g, (match, char) => ESCAPES[char] ?? match);
42
+ // `VALUE # comment` → `VALUE`. Requires the whitespace, so `pa#ss` (no space)
43
+ // stays whole; a value that really needs " #" has to be quoted.
44
+ const strip_inline_comment = (value) => value.replace(/\s+#.*$/, '');
45
+ const preview = (line) => line.length > 40 ? `${line.slice(0, 40)}…` : line;
46
+ const parse_env = (content, source = '.env') => {
47
+ // A BOM would otherwise become part of the first variable's name.
48
+ const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/);
49
+ const pairs = new Map();
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const line_no = i + 1;
52
+ const trimmed = lines[i].trim();
53
+ if (!trimmed || trimmed.startsWith('#'))
54
+ continue;
55
+ const declaration = trimmed.replace(/^export\s+/, '');
56
+ const eq = declaration.indexOf('=');
57
+ if (eq < 0) {
58
+ throw new Error(`${source}:${line_no}: expected KEY=VALUE, got "${preview(declaration)}".`);
59
+ }
60
+ const name = declaration.slice(0, eq).trim();
61
+ if (!name) {
62
+ throw new Error(`${source}:${line_no}: missing name before '='.`);
63
+ }
64
+ const raw = declaration.slice(eq + 1).replace(/^[ \t]*/, '');
65
+ const quote = raw[0] === '"' || raw[0] === "'" ? raw[0] : undefined;
66
+ let value;
67
+ if (quote) {
68
+ let body = raw.slice(1);
69
+ let end = closing_quote(body, quote);
70
+ // Unterminated on this line: keep pulling raw lines in until the quote
71
+ // closes (multi-line values), reporting the line the value STARTED on.
72
+ while (end < 0) {
73
+ i++;
74
+ if (i >= lines.length) {
75
+ throw new Error(`${source}:${line_no}: unterminated ${quote} quote for "${name}".`);
76
+ }
77
+ body += `\n${lines[i]}`;
78
+ end = closing_quote(body, quote);
79
+ }
80
+ const quoted = body.slice(0, end);
81
+ value = quote === '"' ? unescape_double(quoted) : quoted;
82
+ }
83
+ else {
84
+ value = strip_inline_comment(raw).trim();
85
+ }
86
+ // Re-throw with the file position: "exceeds 255 characters" is useless
87
+ // when the file has eighty lines.
88
+ try {
89
+ const pair = validate_pair(name, value);
90
+ pairs.set(pair.name, pair.value);
91
+ }
92
+ catch (err) {
93
+ throw new Error(`${source}:${line_no}: ${err.message}`, {
94
+ cause: err
95
+ });
96
+ }
97
+ }
98
+ return [...pairs.entries()].map(([name, value]) => ({ name, value }));
99
+ };
100
+
101
+ export { parse_env };
@@ -1,6 +1,24 @@
1
1
  // API limits for a secret (mirrors the server-side schema).
2
2
  const NAME_MAX = 255;
3
3
  const VALUE_MAX = 50000;
4
+ // Mirror of the API's secret-name rule (api/src/deploy/secrets/SecretsManager.ts):
5
+ // printable ASCII (0x20-0x7E) except '=' (0x3D). Checked here so a bad name
6
+ // fails before the request instead of 400-ing the whole batch.
7
+ const PRINTABLE_ASCII_NO_EQUALS = /^[\x20-\x3C\x3E-\x7E]+$/;
8
+ // Shared by the KEY=VALUE arguments and the --env-file parser, so both reject
9
+ // the same names and sizes with the same wording.
10
+ const validate_pair = (name, value) => {
11
+ if (name.length > NAME_MAX) {
12
+ throw new Error(`Secret name "${name.slice(0, 32)}…" exceeds ${NAME_MAX} characters.`);
13
+ }
14
+ if (!PRINTABLE_ASCII_NO_EQUALS.test(name)) {
15
+ throw new Error(`Invalid secret name "${name}". Names must be printable ASCII and cannot contain '='.`);
16
+ }
17
+ if (value.length > VALUE_MAX) {
18
+ throw new Error(`Value for "${name}" exceeds ${VALUE_MAX} characters.`);
19
+ }
20
+ return { name, value };
21
+ };
4
22
  // Split each "KEY=VALUE" on the FIRST '=' only, so values may contain '='.
5
23
  // An empty value ("KEY=") is allowed — it is a legitimate way to blank a
6
24
  // secret. Throws on the first invalid pair so callers can validate the whole
@@ -11,16 +29,8 @@ const parse_pairs = (inputs) => {
11
29
  if (idx <= 0) {
12
30
  throw new Error(`Invalid secret "${raw}". Expected KEY=VALUE (e.g. DATABASE_URL=postgres://...).`);
13
31
  }
14
- const name = raw.slice(0, idx);
15
- const value = raw.slice(idx + 1);
16
- if (name.length > NAME_MAX) {
17
- throw new Error(`Secret name "${name.slice(0, 32)}…" exceeds ${NAME_MAX} characters.`);
18
- }
19
- if (value.length > VALUE_MAX) {
20
- throw new Error(`Value for "${name}" exceeds ${VALUE_MAX} characters.`);
21
- }
22
- return { name, value };
32
+ return validate_pair(raw.slice(0, idx), raw.slice(idx + 1));
23
33
  });
24
34
  };
25
35
 
26
- export { NAME_MAX, VALUE_MAX, parse_pairs };
36
+ export { NAME_MAX, VALUE_MAX, parse_pairs, validate_pair };
@@ -1,31 +1,96 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { requireApi } from '../../../api/context.js';
2
4
  import { log } from '../../../log.js';
3
5
  import { resolve_app_id } from '../resolve_app_id.js';
6
+ import { managed_warning } from './managed_names.js';
4
7
  import { merge_app_secrets } from './merge.js';
8
+ import { parse_env } from './parse_env.js';
5
9
  import { parse_pairs } from './parse_pairs.js';
6
10
 
11
+ // `--env-file` with no value means "the .env in this directory", the case
12
+ // people actually have.
13
+ const DEFAULT_ENV_FILE = '.env';
14
+ const read_env_file = (raw_path) => {
15
+ const path = raw_path || DEFAULT_ENV_FILE;
16
+ let content;
17
+ try {
18
+ content = readFileSync(resolve(path), 'utf8');
19
+ }
20
+ catch (err) {
21
+ const code = err.code;
22
+ if (code === 'ENOENT') {
23
+ throw new Error(`No such file: ${path}`, { cause: err });
24
+ }
25
+ if (code === 'EISDIR') {
26
+ throw new Error(`${path} is a directory, expected a .env file.`, {
27
+ cause: err
28
+ });
29
+ }
30
+ throw err;
31
+ }
32
+ return parse_env(content, path);
33
+ };
34
+ // Both sources upserted into one list, arguments last so an explicit
35
+ // KEY=VALUE on the command line overrides the same name in the file.
36
+ const combine = (from_file, from_args) => {
37
+ const merged = new Map(from_file.map(p => [p.name, p.value]));
38
+ for (const { name, value } of from_args)
39
+ merged.set(name, value);
40
+ return [...merged.entries()].map(([name, value]) => ({ name, value }));
41
+ };
42
+ // One line per secret reads well for a handful; a whole .env would bury the
43
+ // summary under eighty lines.
44
+ const MAX_DETAILED = 10;
7
45
  const secrets_set = {
8
- command: 'set <pairs...>',
9
- describe: 'Set one or more secrets as KEY=VALUE',
46
+ command: 'set [pairs...]',
47
+ describe: 'Set secrets as KEY=VALUE pairs or from a .env file',
10
48
  builder: yargs => yargs
11
49
  .positional('pairs', {
12
50
  type: 'string',
13
51
  array: true,
14
- demandOption: true,
15
52
  description: 'KEY=VALUE pairs (quote values containing spaces)'
16
53
  })
17
54
  .option('app', {
18
55
  alias: 'a',
19
56
  type: 'string',
20
57
  description: 'App Identifier (defaults to the linked app)'
58
+ })
59
+ .option('env-file', {
60
+ alias: 'f',
61
+ type: 'string',
62
+ description: 'Load variables from a .env file (defaults to ./.env)'
21
63
  })
22
64
  .example('$0 deploy secrets set API_KEY=abc123', 'Set a single secret')
23
65
  .example('$0 deploy secrets set A=1 DB_URL=postgres://u:p@host/db', 'Set several at once (values may contain "=")')
66
+ .example('$0 deploy secrets set --env-file', 'Upload every variable in ./.env')
67
+ .example('$0 deploy secrets set -f .env.production', 'Upload another env file')
24
68
  .showHelpOnFail(false),
25
69
  handler: async (args) => {
26
- // Validate ALL pairs before writing ANY, so one malformed pair aborts the
27
- // whole command with no partial writes.
28
- const parsed = parse_pairs(args.pairs);
70
+ const env_file = args['env-file'];
71
+ const pairs = args.pairs ?? [];
72
+ if (env_file === undefined && pairs.length === 0) {
73
+ throw new Error('Nothing to set. Pass KEY=VALUE pairs or --env-file [path] to load a .env file.');
74
+ }
75
+ // Validate EVERYTHING before writing ANY, so one malformed line or pair
76
+ // aborts the whole command with no partial writes.
77
+ const from_file = env_file === undefined ? [] : read_env_file(env_file);
78
+ const parsed = combine(from_file, parse_pairs(pairs));
79
+ if (env_file !== undefined) {
80
+ const path = env_file || DEFAULT_ENV_FILE;
81
+ if (from_file.length === 0) {
82
+ throw new Error(`${path} has no variables to set.`);
83
+ }
84
+ log.info(`📄 Read ${from_file.length} variable(s) from ${path}`);
85
+ }
86
+ // Warn BEFORE the write: a reserved name is accepted and stored, but the
87
+ // controller drops it when it builds the pod — without this line the
88
+ // command looks like it worked.
89
+ for (const { name } of parsed) {
90
+ const warning = managed_warning(name);
91
+ if (warning)
92
+ log.warn(`⚠️ ${warning}`);
93
+ }
29
94
  const ctx = await requireApi();
30
95
  const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
31
96
  // getApp also validates access to the app and provides the team the
@@ -35,10 +100,16 @@ const secrets_set = {
35
100
  const merged = merge_app_secrets(existing, parsed);
36
101
  await ctx.api.createSecretsBatch(app.id, app.team, merged);
37
102
  const current = new Set(existing.filter(s => s.related_model === 'app').map(s => s.name));
38
- for (const { name } of parsed) {
39
- log.info(current.has(name)
40
- ? `🔑 Updated secret ${name} on ${app_id}`
41
- : `🔑 Added secret ${name} to ${app_id}`);
103
+ const updated = parsed.filter(({ name }) => current.has(name)).length;
104
+ if (parsed.length <= MAX_DETAILED) {
105
+ for (const { name } of parsed) {
106
+ log.info(current.has(name)
107
+ ? `🔑 Updated secret ${name} on ${app_id}`
108
+ : `🔑 Added secret ${name} to ${app_id}`);
109
+ }
110
+ }
111
+ else {
112
+ log.info(`🔑 ${parsed.length - updated} added, ${updated} updated`);
42
113
  }
43
114
  log.info(`✅ ${parsed.length} secret(s) saved to ${app_id}.`);
44
115
  log.info(`ℹ️ The app is restarting to apply the changes.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",