@vigilbase/cli 0.1.4 → 0.2.1

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 (191) hide show
  1. package/CLOUDFLARE.md +3 -4
  2. package/COMMANDS.md +158 -83
  3. package/LOCAL.md +721 -0
  4. package/ONE.md +2 -4
  5. package/README.md +341 -123
  6. package/SECURITY.md +2 -2
  7. package/dist/auth/device-auth.d.ts +0 -1
  8. package/dist/auth/device-auth.js +13 -8
  9. package/dist/auth/index.d.ts +4 -2
  10. package/dist/auth/index.js +3 -2
  11. package/dist/auth/provider.d.ts +18 -0
  12. package/dist/auth/provider.js +71 -0
  13. package/dist/auth/refresh.d.ts +29 -0
  14. package/dist/auth/refresh.js +199 -0
  15. package/dist/auth/store.d.ts +103 -4
  16. package/dist/auth/store.js +400 -74
  17. package/dist/auth/token-metadata.d.ts +9 -0
  18. package/dist/auth/token-metadata.js +21 -0
  19. package/dist/auth/types.d.ts +8 -2
  20. package/dist/auth/types.js +0 -1
  21. package/dist/bin.d.ts +0 -1
  22. package/dist/bin.js +0 -1
  23. package/dist/cli.d.ts +4 -2
  24. package/dist/cli.js +45 -15
  25. package/dist/commands/account.d.ts +0 -1
  26. package/dist/commands/account.js +0 -1
  27. package/dist/commands/auth.d.ts +0 -1
  28. package/dist/commands/auth.js +82 -22
  29. package/dist/commands/billing.d.ts +0 -1
  30. package/dist/commands/billing.js +0 -1
  31. package/dist/commands/cloudflare.d.ts +1 -2
  32. package/dist/commands/cloudflare.js +6 -6
  33. package/dist/commands/helpers.d.ts +15 -1
  34. package/dist/commands/helpers.js +2 -3
  35. package/dist/commands/index.d.ts +3 -2
  36. package/dist/commands/index.js +6 -2
  37. package/dist/commands/legacy-validation.d.ts +0 -1
  38. package/dist/commands/legacy-validation.js +0 -1
  39. package/dist/commands/local.d.ts +35 -0
  40. package/dist/commands/local.js +1543 -0
  41. package/dist/commands/one.d.ts +0 -1
  42. package/dist/commands/one.js +6 -7
  43. package/dist/commands/public-tools.d.ts +0 -1
  44. package/dist/commands/public-tools.js +55 -17
  45. package/dist/commands/runs.d.ts +0 -1
  46. package/dist/commands/runs.js +0 -1
  47. package/dist/commands/security.d.ts +1 -2
  48. package/dist/commands/security.js +1 -2
  49. package/dist/commands/watchdog.d.ts +0 -1
  50. package/dist/commands/watchdog.js +0 -1
  51. package/dist/index.d.ts +3 -3
  52. package/dist/index.js +1 -2
  53. package/dist/lib/banner.d.ts +0 -1
  54. package/dist/lib/banner.js +0 -1
  55. package/dist/lib/client.d.ts +4 -2
  56. package/dist/lib/client.js +42 -18
  57. package/dist/lib/cloudflare-client.d.ts +0 -1
  58. package/dist/lib/cloudflare-client.js +0 -1
  59. package/dist/lib/config.d.ts +31 -3
  60. package/dist/lib/config.js +72 -13
  61. package/dist/lib/format.d.ts +2 -1
  62. package/dist/lib/format.js +27 -7
  63. package/dist/lib/help.d.ts +1 -2
  64. package/dist/lib/help.js +37 -17
  65. package/dist/lib/network.d.ts +10 -1
  66. package/dist/lib/network.js +33 -5
  67. package/dist/lib/one-client.d.ts +5 -3
  68. package/dist/lib/one-client.js +49 -3
  69. package/dist/lib/one-operations.d.ts +0 -1
  70. package/dist/lib/one-operations.js +0 -1
  71. package/dist/lib/open-url.d.ts +0 -1
  72. package/dist/lib/open-url.js +0 -1
  73. package/dist/lib/platform.d.ts +2 -0
  74. package/dist/lib/platform.js +4 -0
  75. package/dist/lib/progress.d.ts +0 -1
  76. package/dist/lib/progress.js +0 -1
  77. package/dist/lib/security-client.d.ts +1 -2
  78. package/dist/lib/security-client.js +0 -1
  79. package/dist/lib/skill-fetch.d.ts +0 -1
  80. package/dist/lib/skill-fetch.js +0 -1
  81. package/dist/lib/system-trust.d.ts +0 -1
  82. package/dist/lib/system-trust.js +0 -1
  83. package/dist/lib/tui.d.ts +308 -0
  84. package/dist/lib/tui.js +846 -0
  85. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +94 -0
  86. package/dist/local/cloudflare-checkup/cf/allowlist.js +183 -0
  87. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +112 -0
  88. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +312 -0
  89. package/dist/local/cloudflare-checkup/cf/child-env.d.ts +31 -0
  90. package/dist/local/cloudflare-checkup/cf/child-env.js +80 -0
  91. package/dist/local/cloudflare-checkup/cf/install.d.ts +105 -0
  92. package/dist/local/cloudflare-checkup/cf/install.js +308 -0
  93. package/dist/local/cloudflare-checkup/cf/locate.d.ts +43 -0
  94. package/dist/local/cloudflare-checkup/cf/locate.js +227 -0
  95. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +88 -0
  96. package/dist/local/cloudflare-checkup/cf/run-cf.js +274 -0
  97. package/dist/local/cloudflare-checkup/collector.d.ts +47 -0
  98. package/dist/local/cloudflare-checkup/collector.js +295 -0
  99. package/dist/local/cloudflare-checkup/contract.d.ts +2804 -0
  100. package/dist/local/cloudflare-checkup/contract.js +1620 -0
  101. package/dist/local/cloudflare-checkup/email-verification.d.ts +124 -0
  102. package/dist/local/cloudflare-checkup/email-verification.js +211 -0
  103. package/dist/local/cloudflare-checkup/errors.d.ts +29 -0
  104. package/dist/local/cloudflare-checkup/errors.js +103 -0
  105. package/dist/local/cloudflare-checkup/output.d.ts +23 -0
  106. package/dist/local/cloudflare-checkup/output.js +88 -0
  107. package/dist/local/cloudflare-checkup/prompts.d.ts +40 -0
  108. package/dist/local/cloudflare-checkup/prompts.js +139 -0
  109. package/dist/local/cloudflare-checkup/rate-budget.d.ts +41 -0
  110. package/dist/local/cloudflare-checkup/rate-budget.js +79 -0
  111. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +52 -0
  112. package/dist/local/cloudflare-checkup/snapshot-file.js +185 -0
  113. package/dist/local/cloudflare-checkup/submit-client.d.ts +112 -0
  114. package/dist/local/cloudflare-checkup/submit-client.js +395 -0
  115. package/dist/local/cloudflare-checkup/terminal-ui.d.ts +170 -0
  116. package/dist/local/cloudflare-checkup/terminal-ui.js +403 -0
  117. package/dist/types.d.ts +25 -3
  118. package/dist/types.js +16 -1
  119. package/dist/version.d.ts +0 -1
  120. package/dist/version.js +0 -1
  121. package/package.json +12 -12
  122. package/dist/auth/device-auth.d.ts.map +0 -1
  123. package/dist/auth/device-auth.js.map +0 -1
  124. package/dist/auth/index.d.ts.map +0 -1
  125. package/dist/auth/index.js.map +0 -1
  126. package/dist/auth/store.d.ts.map +0 -1
  127. package/dist/auth/store.js.map +0 -1
  128. package/dist/auth/types.d.ts.map +0 -1
  129. package/dist/auth/types.js.map +0 -1
  130. package/dist/bin.d.ts.map +0 -1
  131. package/dist/bin.js.map +0 -1
  132. package/dist/cli.d.ts.map +0 -1
  133. package/dist/cli.js.map +0 -1
  134. package/dist/commands/account.d.ts.map +0 -1
  135. package/dist/commands/account.js.map +0 -1
  136. package/dist/commands/auth.d.ts.map +0 -1
  137. package/dist/commands/auth.js.map +0 -1
  138. package/dist/commands/billing.d.ts.map +0 -1
  139. package/dist/commands/billing.js.map +0 -1
  140. package/dist/commands/cloudflare.d.ts.map +0 -1
  141. package/dist/commands/cloudflare.js.map +0 -1
  142. package/dist/commands/helpers.d.ts.map +0 -1
  143. package/dist/commands/helpers.js.map +0 -1
  144. package/dist/commands/index.d.ts.map +0 -1
  145. package/dist/commands/index.js.map +0 -1
  146. package/dist/commands/legacy-validation.d.ts.map +0 -1
  147. package/dist/commands/legacy-validation.js.map +0 -1
  148. package/dist/commands/one.d.ts.map +0 -1
  149. package/dist/commands/one.js.map +0 -1
  150. package/dist/commands/public-tools.d.ts.map +0 -1
  151. package/dist/commands/public-tools.js.map +0 -1
  152. package/dist/commands/runs.d.ts.map +0 -1
  153. package/dist/commands/runs.js.map +0 -1
  154. package/dist/commands/security.d.ts.map +0 -1
  155. package/dist/commands/security.js.map +0 -1
  156. package/dist/commands/watchdog.d.ts.map +0 -1
  157. package/dist/commands/watchdog.js.map +0 -1
  158. package/dist/index.d.ts.map +0 -1
  159. package/dist/index.js.map +0 -1
  160. package/dist/lib/banner.d.ts.map +0 -1
  161. package/dist/lib/banner.js.map +0 -1
  162. package/dist/lib/client.d.ts.map +0 -1
  163. package/dist/lib/client.js.map +0 -1
  164. package/dist/lib/cloudflare-client.d.ts.map +0 -1
  165. package/dist/lib/cloudflare-client.js.map +0 -1
  166. package/dist/lib/config.d.ts.map +0 -1
  167. package/dist/lib/config.js.map +0 -1
  168. package/dist/lib/format.d.ts.map +0 -1
  169. package/dist/lib/format.js.map +0 -1
  170. package/dist/lib/help.d.ts.map +0 -1
  171. package/dist/lib/help.js.map +0 -1
  172. package/dist/lib/network.d.ts.map +0 -1
  173. package/dist/lib/network.js.map +0 -1
  174. package/dist/lib/one-client.d.ts.map +0 -1
  175. package/dist/lib/one-client.js.map +0 -1
  176. package/dist/lib/one-operations.d.ts.map +0 -1
  177. package/dist/lib/one-operations.js.map +0 -1
  178. package/dist/lib/open-url.d.ts.map +0 -1
  179. package/dist/lib/open-url.js.map +0 -1
  180. package/dist/lib/progress.d.ts.map +0 -1
  181. package/dist/lib/progress.js.map +0 -1
  182. package/dist/lib/security-client.d.ts.map +0 -1
  183. package/dist/lib/security-client.js.map +0 -1
  184. package/dist/lib/skill-fetch.d.ts.map +0 -1
  185. package/dist/lib/skill-fetch.js.map +0 -1
  186. package/dist/lib/system-trust.d.ts.map +0 -1
  187. package/dist/lib/system-trust.js.map +0 -1
  188. package/dist/types.d.ts.map +0 -1
  189. package/dist/types.js.map +0 -1
  190. package/dist/version.d.ts.map +0 -1
  191. package/dist/version.js.map +0 -1
@@ -1,84 +1,147 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, rename, rm } from 'node:fs/promises';
4
- import { dirname, isAbsolute, join, parse, resolve } from 'node:path';
4
+ import { basename, dirname, isAbsolute, join, parse, resolve } from 'node:path';
5
5
  import { z } from 'zod';
6
+ import { DEFAULT_TIMEOUT_MS } from '../lib/config.js';
7
+ import { platform } from '../lib/platform.js';
6
8
  const FILE_NAME = 'credentials.json';
9
+ /** CLI 0.1.x rejects a version 2 credentials.json and deletes it unrevoked, so Identity grants live apart. */
10
+ const IDENTITY_FILE_NAME = 'identity-credentials.json';
7
11
  const MODE_DIR = 0o700;
8
12
  const MODE_FILE = 0o600;
9
- const DEFAULT_ONE_ORIGIN = 'https://one.vigilbase.com';
10
- const OneCredentialsSchema = z.object({
13
+ export const DEFAULT_ONE_ORIGIN = 'https://one.vigilbase.com';
14
+ const INVALID_ONE_CREDENTIALS = 'Saved One credentials are invalid. Run `vigilbase logout`, then `vigilbase login` to replace them.';
15
+ const INVALID_IDENTITY_CREDENTIALS = 'Saved Identity credentials are invalid. Run `vigilbase login` to replace them.';
16
+ export const UNCERTAIN_REFRESH = 'A previous refresh has an uncertain result. Run `vigilbase login` again; its token cannot be retried safely.';
17
+ const SAVED_FOR_ANOTHER_ORIGIN = 'Saved for a different One origin, Identity issuer or client ID.';
18
+ export const OneCredentialsSchema = z.object({
19
+ issuer: z.string().optional(), clientId: z.string().optional(), resource: z.string().optional(), refreshToken: z.string().min(1).optional(), refreshTokenExpiresAt: z.string().optional(), refreshPending: z.boolean().optional(), orgId: z.string().optional(),
11
20
  oneBaseUrl: z.string().optional(), accessToken: z.string().min(1), tokenType: z.string().optional(),
12
21
  scope: z.string().optional(), expiresAt: z.string().optional(), obtainedAt: z.string(),
13
22
  user: z.object({ id: z.string().optional(), email: z.string().optional(), name: z.string().optional() }).optional(),
14
23
  });
15
- const CredentialsSchema = z.object({ version: z.literal(1), one: OneCredentialsSchema.nullable().optional() });
16
- export function credentialsPath(config) {
24
+ const CredentialsSchema = z.object({ version: z.union([z.literal(1), z.literal(2)]), one: OneCredentialsSchema.nullable().optional() });
25
+ function configFilePath(config, name) {
17
26
  if (!isAbsolute(config.configDir))
18
- throw new Error('The credentials directory must be an absolute path.');
19
- return join(resolve(config.configDir), FILE_NAME);
27
+ throw relativeDirectoryError(config.configDir);
28
+ return join(resolve(config.configDir), name);
29
+ }
30
+ /** Version 1 One session credentials, shared with CLI 0.1.x. */
31
+ export function credentialsPath(config) {
32
+ return configFilePath(config, FILE_NAME);
33
+ }
34
+ /** Version 2 Identity credentials with the rotating refresh grant. */
35
+ export function identityCredentialsPath(config) {
36
+ return configFilePath(config, IDENTITY_FILE_NAME);
20
37
  }
21
38
  function isMissing(error) {
22
39
  return error instanceof Error && 'code' in error && error.code === 'ENOENT';
23
40
  }
24
- function assertOwner(info, label) {
25
- if (typeof process.getuid === 'function' && info.uid !== process.getuid()) {
26
- throw new Error(`${label} must be owned by the current user.`);
41
+ function isOwnedByOtherUser(info) {
42
+ return typeof process.getuid === 'function' && info.uid !== process.getuid();
43
+ }
44
+ /** A credentials file that passed the safety checks but cannot be parsed or is incomplete. */
45
+ export class InvalidCredentialsError extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = 'InvalidCredentialsError';
27
49
  }
28
50
  }
29
- function assertFile(info) {
30
- if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) {
31
- throw new Error('Credentials must be a regular file with no symbolic or hard links.');
51
+ /** Credentials cannot be protected by modes and owners on Windows yet. */
52
+ export class SignInUnsupportedError extends Error {
53
+ code = 'SIGN_IN_UNSUPPORTED';
54
+ constructor() {
55
+ super("Signing in isn't supported on Windows yet; public tools and cloudflare-checkup still work.");
56
+ this.name = 'SignInUnsupportedError';
32
57
  }
33
- assertOwner(info, 'Credentials file');
34
- if ((info.mode & 0o777) !== MODE_FILE) {
35
- throw new Error('Credentials file must have mode 0600. Run chmod 600 on credentials.json before continuing.');
58
+ }
59
+ /**
60
+ * A credentials directory or file the CLI refuses to use. `reason` names the path that failed and `remedy`
61
+ * says how to fix it; `vigilbase login` can't, because it runs the same checks.
62
+ */
63
+ export class CredentialsPathError extends Error {
64
+ reason;
65
+ remedy;
66
+ constructor(reason, remedy, options) {
67
+ super(`${reason}. ${remedy}`, options);
68
+ this.reason = reason;
69
+ this.remedy = remedy;
70
+ this.name = 'CredentialsPathError';
36
71
  }
37
72
  }
38
- /** Check every ancestor before traversing it; a writable non-sticky parent permits replacement. */
39
- async function checkDirectory(configDir, isCreate = false) {
73
+ const REMOVE_FILE = 'Remove it, or set VIGILBASE_CONFIG_DIR to another directory.';
74
+ export function assertFile(info, path) {
75
+ if (info.isSymbolicLink())
76
+ throw new CredentialsPathError(`${path} is a symbolic link`, REMOVE_FILE);
77
+ if (!info.isFile())
78
+ throw new CredentialsPathError(`${path} is not a regular file`, REMOVE_FILE);
79
+ if (info.nlink !== 1)
80
+ throw new CredentialsPathError(`${path} has more than one hard link`, REMOVE_FILE);
81
+ if (isOwnedByOtherUser(info))
82
+ throw new CredentialsPathError(`${path} is owned by another user`, REMOVE_FILE);
83
+ if ((info.mode & 0o777) !== MODE_FILE)
84
+ throw new CredentialsPathError(`${path} must have mode 0600`, `Run chmod 600 ${path}.`);
85
+ }
86
+ const USE_ANOTHER_DIRECTORY = 'Set VIGILBASE_CONFIG_DIR to a private directory without symbolic links in its path.';
87
+ function relativeDirectoryError(configDir) {
88
+ return new CredentialsPathError(`${configDir} is not an absolute path`, 'Set VIGILBASE_CONFIG_DIR to an absolute path.');
89
+ }
90
+ /**
91
+ * Check every ancestor before traversing it; a writable non-sticky parent permits replacement.
92
+ * Every credentials read or write starts here, so on Windows they fail before touching the filesystem.
93
+ */
94
+ export async function checkDirectory(configDir, isCreate = false) {
95
+ if (platform() === 'win32')
96
+ throw new SignInUnsupportedError();
40
97
  if (!isAbsolute(configDir))
41
- throw new Error('The credentials directory must be an absolute path.');
98
+ throw relativeDirectoryError(configDir);
42
99
  const absolute = resolve(configDir);
43
- const root = parse(absolute).root;
44
- const components = absolute.slice(root.length).split('/').filter(Boolean);
45
- let current = root;
46
- for (const [index, component] of components.entries()) {
47
- current = join(current, component);
100
+ const { root } = parse(absolute);
101
+ if (absolute === root)
102
+ throw new CredentialsPathError('The filesystem root cannot be the credentials directory', USE_ANOTHER_DIRECTORY);
103
+ // Each ancestor below the root, ending with the credentials directory itself.
104
+ const paths = [];
105
+ for (let path = absolute; path !== root; path = dirname(path))
106
+ paths.unshift(path);
107
+ for (const [index, path] of paths.entries()) {
48
108
  let info;
49
109
  try {
50
- info = await lstat(current);
110
+ info = await lstat(path);
51
111
  }
52
112
  catch (error) {
53
- if (!isMissing(error))
54
- throw error;
113
+ if (!isMissing(error)) {
114
+ const code = error instanceof Error && 'code' in error ? ` (${String(error.code)})` : '';
115
+ throw new CredentialsPathError(`${path} cannot be checked${code}`, USE_ANOTHER_DIRECTORY, { cause: error });
116
+ }
55
117
  if (!isCreate)
56
118
  return false;
57
- await mkdir(current, { mode: MODE_DIR });
58
- info = await lstat(current);
119
+ await mkdir(path, { mode: MODE_DIR });
120
+ info = await lstat(path);
59
121
  }
60
- if (!info.isDirectory() || info.isSymbolicLink())
61
- throw new Error('Credentials directory and its parents must be directories, without symbolic links.');
62
- if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) {
63
- throw new Error('Credentials directory parents must not be writable by other users unless protected by the sticky bit.');
64
- }
65
- if (typeof process.getuid === 'function' && info.uid !== process.getuid() && info.uid !== 0) {
66
- throw new Error('Credentials directory parents must be owned by the current user or root.');
67
- }
68
- if (index === components.length - 1) {
69
- assertOwner(info, 'Credentials directory');
122
+ if (info.isSymbolicLink())
123
+ throw new CredentialsPathError(`${path} is a symbolic link`, USE_ANOTHER_DIRECTORY);
124
+ if (!info.isDirectory())
125
+ throw new CredentialsPathError(`${path} is not a directory`, USE_ANOTHER_DIRECTORY);
126
+ if (index === paths.length - 1) {
127
+ if (isOwnedByOtherUser(info))
128
+ throw new CredentialsPathError(`${path} is owned by another user`, USE_ANOTHER_DIRECTORY);
70
129
  if ((info.mode & 0o777) !== MODE_DIR) {
71
- throw new Error('Credentials directory must have mode 0700. Run chmod 700 on your Vigilbase config directory before continuing.');
130
+ throw new CredentialsPathError(`${path} must have mode 0700`, `Run chmod 700 ${path}, or set VIGILBASE_CONFIG_DIR to another directory.`);
72
131
  }
73
132
  }
133
+ else {
134
+ if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0)
135
+ throw new CredentialsPathError(`${path} is writable by other users`, USE_ANOTHER_DIRECTORY);
136
+ if (isOwnedByOtherUser(info) && info.uid !== 0)
137
+ throw new CredentialsPathError(`${path} is owned by another user`, USE_ANOTHER_DIRECTORY);
138
+ }
74
139
  }
75
- if (!components.length)
76
- throw new Error('The filesystem root cannot be a credentials directory.');
77
140
  return true;
78
141
  }
79
142
  async function checkExistingFile(path) {
80
143
  try {
81
- assertFile(await lstat(path));
144
+ assertFile(await lstat(path), path);
82
145
  return true;
83
146
  }
84
147
  catch (error) {
@@ -87,39 +150,173 @@ async function checkExistingFile(path) {
87
150
  throw error;
88
151
  }
89
152
  }
90
- export async function readCredentials(config) {
91
- const path = credentialsPath(config);
92
- if (!await checkDirectory(dirname(path)))
93
- return { version: 1, one: null };
153
+ /** Parse one credentials file; the Identity file must hold a complete version 2 grant. */
154
+ async function readCredentialsFile(path) {
94
155
  if (!await checkExistingFile(path))
95
- return { version: 1, one: null };
156
+ return null;
157
+ const isIdentityFile = basename(path) === IDENTITY_FILE_NAME;
158
+ const invalid = () => new InvalidCredentialsError(isIdentityFile ? INVALID_IDENTITY_CREDENTIALS : INVALID_ONE_CREDENTIALS);
96
159
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
97
160
  try {
98
- assertFile(await handle.stat());
161
+ assertFile(await handle.stat(), path);
99
162
  const parsed = CredentialsSchema.safeParse(JSON.parse(await handle.readFile('utf8')));
100
163
  if (!parsed.success)
101
- throw new Error('Saved One credentials are invalid. Run `vigilbase logout`, then `vigilbase login` to replace them.');
102
- // Zod strips legacy refresh tokens and unknown fields from in-memory results.
164
+ throw invalid();
165
+ // Keep legacy session credentials separate from rotating Identity grants.
166
+ if (parsed.data.version === 1 && parsed.data.one)
167
+ delete parsed.data.one.refreshToken;
168
+ if ((parsed.data.version === 2 || isIdentityFile) && (parsed.data.version !== 2 || !parsed.data.one?.issuer || !parsed.data.one.clientId || !parsed.data.one.resource || !parsed.data.one.refreshToken))
169
+ throw new InvalidCredentialsError(INVALID_IDENTITY_CREDENTIALS);
103
170
  return parsed.data;
104
171
  }
105
172
  catch (error) {
106
173
  if (error instanceof SyntaxError)
107
- throw new Error('Saved One credentials are invalid. Run `vigilbase logout`, then `vigilbase login` to replace them.');
174
+ throw invalid();
108
175
  throw error;
109
176
  }
110
177
  finally {
111
178
  await handle.close();
112
179
  }
113
180
  }
114
- export async function writeOneCredentials(config, one) {
181
+ /**
182
+ * With `onInvalid`, a safe file that cannot be parsed reads as absent and is reported; unsafe files always throw.
183
+ * Without it, an unparseable credentials.json reads as absent when identity-credentials.json holds a sign-in, which wins anyway.
184
+ */
185
+ export async function readStoredCredentials(config, onInvalid) {
186
+ if (!await checkDirectory(dirname(credentialsPath(config))))
187
+ return { identity: null, shared: null };
188
+ const read = async (path) => {
189
+ try {
190
+ return await readCredentialsFile(path);
191
+ }
192
+ catch (error) {
193
+ if (!onInvalid || !(error instanceof InvalidCredentialsError))
194
+ throw error;
195
+ onInvalid(path);
196
+ return null;
197
+ }
198
+ };
199
+ // Read credentials.json first: a concurrent migration renames it to identity-credentials.json.
200
+ let shared = null;
201
+ let invalidShared = null;
202
+ try {
203
+ shared = await read(credentialsPath(config));
204
+ }
205
+ catch (error) {
206
+ if (!(error instanceof InvalidCredentialsError))
207
+ throw error;
208
+ invalidShared = error;
209
+ }
210
+ const identity = await read(identityCredentialsPath(config));
211
+ if (invalidShared && !identity)
212
+ throw invalidShared;
213
+ return { identity, shared };
214
+ }
215
+ /** Identity credentials win over credentials.json everywhere tokens are read. */
216
+ function currentCredentials({ identity, shared }) {
217
+ return identity ?? shared ?? { version: 1, one: null };
218
+ }
219
+ /**
220
+ * CLI 0.2.0 kept its Identity grant in credentials.json, which CLI 0.1.x rejects.
221
+ * Move it to identity-credentials.json unless that file exists. Callers hold credentials.json.lock.
222
+ */
223
+ async function migrateIdentityCredentials(config) {
115
224
  const path = credentialsPath(config);
225
+ const identityPath = identityCredentialsPath(config);
226
+ if (!await checkDirectory(dirname(path)) || await checkExistingFile(identityPath))
227
+ return;
228
+ // An unparseable credentials.json holds no grant to move.
229
+ const shared = await readCredentialsFile(path).catch((error) => {
230
+ if (error instanceof InvalidCredentialsError)
231
+ return null;
232
+ throw error;
233
+ });
234
+ if (shared?.version !== 2)
235
+ return;
236
+ // Re-check both paths immediately before the rename, as the write path does.
237
+ await checkDirectory(dirname(path));
238
+ if (!await checkExistingFile(path) || await checkExistingFile(identityPath))
239
+ return;
240
+ await rename(path, identityPath);
241
+ }
242
+ /** Identity credentials win. The first read moves a CLI 0.2.0 credentials.json aside under the lock. */
243
+ export async function readCredentials(config) {
244
+ const stored = await readStoredCredentials(config);
245
+ if (stored.identity || stored.shared?.version !== 2)
246
+ return currentCredentials(stored);
247
+ const { withCredentialsLock } = await import('./refresh.js');
248
+ return withCredentialsLock(config, () => readLockedCredentials(config));
249
+ }
250
+ /** readCredentials that never waits: while another command holds the lock, a CLI 0.2.0 credentials.json is read in place. */
251
+ async function readCredentialsWithoutWaiting(config) {
252
+ const stored = await readStoredCredentials(config);
253
+ if (stored.identity || stored.shared?.version !== 2)
254
+ return currentCredentials(stored);
255
+ const { CredentialsLockedError, withCredentialsLock } = await import('./refresh.js');
256
+ try {
257
+ return await withCredentialsLock(config, () => readLockedCredentials(config), false, false);
258
+ }
259
+ catch (error) {
260
+ if (error instanceof CredentialsLockedError)
261
+ return currentCredentials(stored);
262
+ throw error;
263
+ }
264
+ }
265
+ /** readCredentials for callers that already hold credentials.json.lock. */
266
+ export async function readLockedCredentials(config) {
267
+ await migrateIdentityCredentials(config);
268
+ return currentCredentials(await readStoredCredentials(config));
269
+ }
270
+ /**
271
+ * The Identity grant that saving a new one replaces: identity-credentials.json's, or that of a CLI 0.2.0
272
+ * credentials.json which saving moves there. An unparseable file has no grant to revoke. Callers hold the lock.
273
+ */
274
+ export async function readReplacedIdentityGrant(config) {
275
+ if (!await checkDirectory(dirname(credentialsPath(config))))
276
+ return null;
277
+ for (const path of [identityCredentialsPath(config), credentialsPath(config)]) {
278
+ let saved;
279
+ try {
280
+ saved = await readCredentialsFile(path);
281
+ }
282
+ catch (error) {
283
+ if (error instanceof InvalidCredentialsError)
284
+ return null;
285
+ throw error;
286
+ }
287
+ if (saved)
288
+ return saved.version === 2 ? saved.one ?? null : null;
289
+ }
290
+ return null;
291
+ }
292
+ /**
293
+ * Fail before a login starts, rather than after it was approved, when saving would fail: Windows, or an unsafe
294
+ * credentials directory or file. Unparseable files pass; login replaces them.
295
+ */
296
+ export async function checkCredentialsLocation(config) {
297
+ if (!await checkDirectory(dirname(credentialsPath(config))))
298
+ return;
299
+ for (const path of [credentialsPath(config), identityCredentialsPath(config)])
300
+ await checkExistingFile(path);
301
+ }
302
+ /** CLI 0.2.0 can write credentials.json again after migration; that grant is ignored until logout revokes it. */
303
+ export async function hasIgnoredIdentityCredentials(config) {
304
+ const { identity, shared } = await readStoredCredentials(config);
305
+ return Boolean(identity && shared?.version === 2);
306
+ }
307
+ export async function writeOneCredentials(config, one) {
308
+ // Version 2 stores the rotating refresh grant in its own file; credentials.json stays version 1.
309
+ const version = one.issuer ? 2 : 1;
310
+ const path = version === 2 ? identityCredentialsPath(config) : credentialsPath(config);
116
311
  await checkDirectory(dirname(path), true);
312
+ // A new Identity grant also replaces one CLI 0.2.0 left in credentials.json.
313
+ if (version === 2)
314
+ await migrateIdentityCredentials(config);
117
315
  await checkExistingFile(path);
118
- const next = {
119
- version: 1,
120
- // Only needed access-token metadata is persisted. Refresh tokens are unused.
121
- one: OneCredentialsSchema.parse({ ...one, oneBaseUrl: new URL(config.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin }),
122
- };
316
+ const parsedOne = OneCredentialsSchema.parse({ ...one, oneBaseUrl: new URL(config.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin });
317
+ if (!one.issuer)
318
+ delete parsedOne.refreshToken;
319
+ const next = { version, one: parsedOne };
123
320
  const temporaryPath = `${path}.${randomUUID()}.tmp`;
124
321
  const handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, MODE_FILE);
125
322
  try {
@@ -136,32 +333,161 @@ export async function writeOneCredentials(config, one) {
136
333
  await rm(temporaryPath, { force: true });
137
334
  }
138
335
  }
336
+ /** Remove every local credentials file without revoking anything. */
139
337
  export async function clearOneCredentials(config) {
140
- const path = credentialsPath(config);
338
+ const paths = [credentialsPath(config), identityCredentialsPath(config)];
339
+ if (!await checkDirectory(dirname(paths[0])))
340
+ return false;
341
+ // Check both files before removing either, so an unsafe file leaves both in place.
342
+ const existing = [];
343
+ for (const path of paths)
344
+ if (await checkExistingFile(path))
345
+ existing.push(path);
346
+ for (const path of existing)
347
+ await rm(path);
348
+ return existing.length > 0;
349
+ }
350
+ /** Remove identity-credentials.json only; credentials.json stays for CLI 0.1.x. */
351
+ export async function clearIdentityCredentials(config) {
352
+ const path = identityCredentialsPath(config);
141
353
  if (!await checkDirectory(dirname(path)) || !await checkExistingFile(path))
142
354
  return false;
143
355
  await rm(path);
144
356
  return true;
145
357
  }
146
- export async function getOneAccessToken(config) {
147
- const credentials = await readCredentials(config);
148
- const token = credentials.one?.accessToken;
149
- if (typeof token !== 'string' || !token.trim())
150
- return null;
358
+ /** Saved tokens are only sent to the One origin that issued them. */
359
+ function isForOneOrigin(one, config) {
151
360
  try {
152
- const issuer = new URL(credentials.one?.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin;
153
- const target = new URL(config.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin;
154
- if (issuer !== target)
155
- return null;
361
+ return new URL(one.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin === new URL(config.oneBaseUrl ?? DEFAULT_ONE_ORIGIN).origin;
156
362
  }
157
363
  catch {
364
+ return false;
365
+ }
366
+ }
367
+ function hasExpired(one) {
368
+ if (one.expiresAt === undefined)
369
+ return false;
370
+ const expires = Date.parse(one.expiresAt);
371
+ return !Number.isFinite(expires) || expires <= Date.now();
372
+ }
373
+ export async function getOneAccessToken(config) {
374
+ const { version, one } = await readCredentials(config);
375
+ if (!one?.accessToken.trim() || !isForOneOrigin(one, config))
158
376
  return null;
377
+ if (version === 2) {
378
+ const { managedAccessToken } = await import('./refresh.js');
379
+ // Library callers may pass a partial config; never forward an undefined timeout.
380
+ const timeoutMs = typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
381
+ return managedAccessToken({ ...config, timeoutMs });
159
382
  }
160
- if (credentials.one?.expiresAt !== undefined) {
161
- const expires = Date.parse(credentials.one.expiresAt);
162
- if (!Number.isFinite(expires) || expires <= Date.now())
163
- return null;
383
+ return hasExpired(one) ? null : one.accessToken;
384
+ }
385
+ let hasWarnedAboutSignIn = false;
386
+ function isAbsent(error) {
387
+ return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
388
+ }
389
+ /** lstat() only, never open(): whether a credentials file exists, or may exist, in a directory that failed the checks. */
390
+ async function mayHaveCredentials(config) {
391
+ // The CLI never saves credentials under a relative path.
392
+ if (!isAbsolute(config.configDir))
393
+ return false;
394
+ for (const path of [credentialsPath, identityCredentialsPath]) {
395
+ try {
396
+ await lstat(path(config));
397
+ return true;
398
+ }
399
+ catch (error) {
400
+ if (!isAbsent(error))
401
+ return true;
402
+ }
403
+ }
404
+ return false;
405
+ }
406
+ function messageOf(error) {
407
+ return error instanceof Error ? error.message : String(error);
408
+ }
409
+ /**
410
+ * One stderr line per process. The reason is the first sentence of the problem, and the fix is
411
+ * `vigilbase login`, except for an unsafe credentials path, whose own remedy is kept: login would stop on it too.
412
+ */
413
+ function warnOnce(warn, error) {
414
+ if (hasWarnedAboutSignIn)
415
+ return;
416
+ hasWarnedAboutSignIn = true;
417
+ if (error instanceof CredentialsPathError) {
418
+ warn(`Couldn't use your saved sign-in (${error.reason}); running anonymously. ${error.remedy}`);
419
+ return;
420
+ }
421
+ const problem = messageOf(error);
422
+ const reason = problem.split(/\.(?:\s|$)/)[0] || problem;
423
+ warn(`Couldn't use your saved sign-in (${reason}); running anonymously. Run \`vigilbase login\` to fix.`);
424
+ }
425
+ /**
426
+ * The soft directory check for list and doctor: whether the credentials directory exists and passes the checks,
427
+ * and the error to report when it doesn't. A directory that fails them is never written to or opened, and is
428
+ * only a problem when a credentials file may exist there.
429
+ */
430
+ async function checkDirectoryQuietly(config) {
431
+ try {
432
+ return { isUsable: await checkDirectory(dirname(credentialsPath(config))), problem: null };
433
+ }
434
+ catch (error) {
435
+ return { isUsable: false, problem: await mayHaveCredentials(config) ? error : null };
436
+ }
437
+ }
438
+ /**
439
+ * getOneAccessToken for doctor: any problem with the saved sign-in is returned, not thrown.
440
+ * Lock waits, discovery and refresh use the configured timeout. Windows skips the credential store entirely.
441
+ * With no saved sign-in there is no problem, even under a config path that fails the directory checks.
442
+ */
443
+ export async function getOptionalOneAccessToken(config) {
444
+ if (platform() === 'win32')
445
+ return { accessToken: null, problem: null };
446
+ const directory = await checkDirectoryQuietly(config);
447
+ if (!directory.isUsable)
448
+ return { accessToken: null, problem: directory.problem === null ? null : messageOf(directory.problem) };
449
+ try {
450
+ return { accessToken: await getOneAccessToken(config), problem: null };
451
+ }
452
+ catch (error) {
453
+ return { accessToken: null, problem: messageOf(error) };
454
+ }
455
+ }
456
+ /**
457
+ * Whether list can show the saved sign-in as usable, judged from the files alone: no refresh, no network
458
+ * and no wait for the credentials lock. A legacy token must be unexpired; an Identity grant may hold an
459
+ * expired access token, which the next One command refreshes. Windows skips the credential store entirely.
460
+ * Any problem means signed out and one `warn` per process; an expired legacy token is not a problem.
461
+ * A credentials directory that fails the strict checks is never written to or opened, and is only warned
462
+ * about when a credentials file may exist there. Never throws.
463
+ */
464
+ export async function hasUsableSignIn(config, warn) {
465
+ if (platform() === 'win32')
466
+ return false;
467
+ const directory = await checkDirectoryQuietly(config);
468
+ if (!directory.isUsable) {
469
+ if (directory.problem !== null)
470
+ warnOnce(warn, directory.problem);
471
+ return false;
472
+ }
473
+ try {
474
+ const { version, one } = await readCredentialsWithoutWaiting(config);
475
+ if (!one?.accessToken.trim())
476
+ return false;
477
+ if (version === 2) {
478
+ const { boundIdentityCredentials } = await import('./refresh.js');
479
+ if (!boundIdentityCredentials(config, one))
480
+ throw new Error(SAVED_FOR_ANOTHER_ORIGIN);
481
+ if (one.refreshPending)
482
+ throw new Error(UNCERTAIN_REFRESH);
483
+ return true;
484
+ }
485
+ if (!isForOneOrigin(one, config))
486
+ throw new Error(SAVED_FOR_ANOTHER_ORIGIN);
487
+ return !hasExpired(one);
488
+ }
489
+ catch (error) {
490
+ warnOnce(warn, error);
491
+ return false;
164
492
  }
165
- return token;
166
493
  }
167
- //# sourceMappingURL=store.js.map
@@ -0,0 +1,9 @@
1
+ /** Validate grant binding from the trusted issuer's TLS response. One verifies signatures at its API boundary. */
2
+ export declare function readIdentityTokenMetadata(token: string, binding: {
3
+ issuer: string;
4
+ clientId: string;
5
+ resource: string;
6
+ }): {
7
+ organizationId: string;
8
+ subject: string;
9
+ };
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+ /** Validate grant binding from the trusted issuer's TLS response. One verifies signatures at its API boundary. */
3
+ export function readIdentityTokenMetadata(token, binding) {
4
+ try {
5
+ const parts = token.split('.');
6
+ if (parts.length !== 3 || !parts[2])
7
+ throw new Error();
8
+ z.object({ alg: z.literal('EdDSA'), typ: z.literal('at+jwt') }).parse(JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8')));
9
+ const claims = z.object({ iss: z.literal(binding.issuer), sub: z.string().min(1), aud: z.union([z.literal(binding.resource), z.array(z.string()).min(1).max(2)]), azp: z.literal(binding.clientId), iat: z.number().finite(), exp: z.number().finite(), 'https://vigilbase.com/org_id': z.string().min(1) }).parse(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
10
+ const audiences = typeof claims.aud === 'string' ? [claims.aud] : claims.aud;
11
+ if (!audiences.includes(binding.resource) || new Set(audiences).size !== audiences.length || audiences.some(aud => aud !== binding.resource && aud !== `${binding.issuer}/api/auth/oauth2/userinfo`))
12
+ throw new Error();
13
+ const now = Date.now() / 1000;
14
+ if (claims.exp <= now - 30 || claims.iat > now + 30 || claims.exp <= claims.iat || claims.exp - claims.iat > 300)
15
+ throw new Error();
16
+ return { organizationId: claims['https://vigilbase.com/org_id'], subject: claims.sub };
17
+ }
18
+ catch {
19
+ throw new Error('Identity token does not match the expected issuer, client, organization, five-minute lifetime, and exact One audience.');
20
+ }
21
+ }
@@ -12,6 +12,13 @@ export type StoredUser = {
12
12
  export type OneCredentials = {
13
13
  /** Origin that issued this token. Missing on credentials from older CLI versions. */
14
14
  oneBaseUrl?: string;
15
+ issuer?: string;
16
+ clientId?: string;
17
+ resource?: string;
18
+ refreshToken?: string;
19
+ refreshTokenExpiresAt?: string;
20
+ refreshPending?: boolean;
21
+ orgId?: string;
15
22
  accessToken: string;
16
23
  tokenType?: string;
17
24
  scope?: string;
@@ -22,7 +29,7 @@ export type OneCredentials = {
22
29
  obtainedAt: string;
23
30
  };
24
31
  export type CredentialsFile = {
25
- version: 1;
32
+ version: 1 | 2;
26
33
  one?: OneCredentials | null;
27
34
  };
28
35
  export type DeviceCodeResponse = {
@@ -44,4 +51,3 @@ export type DeviceTokenPendingError = {
44
51
  error: 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token' | string;
45
52
  error_description?: string;
46
53
  };
47
- //# sourceMappingURL=types.d.ts.map
@@ -5,4 +5,3 @@
5
5
  * (RFC 8628): https://better-auth.com/docs/plugins/device-authorization
6
6
  */
7
7
  export {};
8
- //# sourceMappingURL=types.js.map
package/dist/bin.d.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=bin.d.ts.map
package/dist/bin.js CHANGED
@@ -4,4 +4,3 @@ import { configureSystemTrust } from './lib/system-trust.js';
4
4
  configureSystemTrust();
5
5
  const code = await runCli();
6
6
  process.exit(code);
7
- //# sourceMappingURL=bin.js.map
package/dist/cli.d.ts CHANGED
@@ -4,11 +4,13 @@ import { CLI_VERSION } from './version.js';
4
4
  export { CLI_VERSION };
5
5
  export type RunCliOptions = {
6
6
  argv?: string[];
7
- stdout?: NodeJS.WritableStream;
7
+ /** Its `isTTY` decides between the banner and the bare version for `--version`. */
8
+ stdout?: NodeJS.WritableStream & {
9
+ isTTY?: boolean;
10
+ };
8
11
  stderr?: NodeJS.WritableStream;
9
12
  stdin?: NodeJS.ReadableStream;
10
13
  env?: NodeJS.ProcessEnv;
11
14
  loginHandler?: (config: ReturnType<typeof createConfig>) => Promise<ExitCode>;
12
15
  };
13
16
  export declare function runCli(options?: RunCliOptions): Promise<ExitCode>;
14
- //# sourceMappingURL=cli.d.ts.map