convex 1.43.0 → 1.44.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +130 -113
  2. package/dist/browser.bundle.js +1 -1
  3. package/dist/browser.bundle.js.map +1 -1
  4. package/dist/cjs/cli/codegen_templates/component_server.js +4 -3
  5. package/dist/cjs/cli/codegen_templates/component_server.js.map +2 -2
  6. package/dist/cjs/cli/codegen_templates/server.js +24 -12
  7. package/dist/cjs/cli/codegen_templates/server.js.map +2 -2
  8. package/dist/cjs/cli/lib/login.js +12 -8
  9. package/dist/cjs/cli/lib/login.js.map +2 -2
  10. package/dist/cjs/cli/login.js +52 -7
  11. package/dist/cjs/cli/login.js.map +2 -2
  12. package/dist/cjs/index.js +1 -1
  13. package/dist/cjs/index.js.map +1 -1
  14. package/dist/cjs/react/ConvexAuthState.js +30 -32
  15. package/dist/cjs/react/ConvexAuthState.js.map +2 -2
  16. package/dist/cjs/server/impl/actions_impl.js +12 -0
  17. package/dist/cjs/server/impl/actions_impl.js.map +2 -2
  18. package/dist/cjs/server/index.js +3 -0
  19. package/dist/cjs/server/index.js.map +2 -2
  20. package/dist/cjs/server/schema.js +69 -1
  21. package/dist/cjs/server/schema.js.map +2 -2
  22. package/dist/cjs-types/cli/codegen_templates/component_server.d.ts.map +1 -1
  23. package/dist/cjs-types/cli/codegen_templates/server.d.ts +1 -0
  24. package/dist/cjs-types/cli/codegen_templates/server.d.ts.map +1 -1
  25. package/dist/cjs-types/cli/lib/login.d.ts +5 -0
  26. package/dist/cjs-types/cli/lib/login.d.ts.map +1 -1
  27. package/dist/cjs-types/cli/login.d.ts.map +1 -1
  28. package/dist/cjs-types/cli/login.test.d.ts +2 -0
  29. package/dist/cjs-types/cli/login.test.d.ts.map +1 -0
  30. package/dist/cjs-types/index.d.ts +1 -1
  31. package/dist/cjs-types/react/ConvexAuthState.d.ts.map +1 -1
  32. package/dist/cjs-types/server/impl/actions_impl.d.ts.map +1 -1
  33. package/dist/cjs-types/server/impl/actions_impl.test.d.ts +2 -0
  34. package/dist/cjs-types/server/impl/actions_impl.test.d.ts.map +1 -0
  35. package/dist/cjs-types/server/index.d.ts +2 -2
  36. package/dist/cjs-types/server/index.d.ts.map +1 -1
  37. package/dist/cjs-types/server/schema.d.ts +83 -5
  38. package/dist/cjs-types/server/schema.d.ts.map +1 -1
  39. package/dist/cli.bundle.cjs +1277 -570
  40. package/dist/cli.bundle.cjs.map +4 -4
  41. package/dist/esm/cli/codegen_templates/component_server.js +8 -4
  42. package/dist/esm/cli/codegen_templates/component_server.js.map +2 -2
  43. package/dist/esm/cli/codegen_templates/server.js +22 -11
  44. package/dist/esm/cli/codegen_templates/server.js.map +2 -2
  45. package/dist/esm/cli/lib/login.js +11 -8
  46. package/dist/esm/cli/lib/login.js.map +2 -2
  47. package/dist/esm/cli/login.js +54 -10
  48. package/dist/esm/cli/login.js.map +2 -2
  49. package/dist/esm/index.js +1 -1
  50. package/dist/esm/index.js.map +1 -1
  51. package/dist/esm/react/ConvexAuthState.js +31 -32
  52. package/dist/esm/react/ConvexAuthState.js.map +2 -2
  53. package/dist/esm/server/impl/actions_impl.js +11 -0
  54. package/dist/esm/server/impl/actions_impl.js.map +2 -2
  55. package/dist/esm/server/index.js +2 -1
  56. package/dist/esm/server/index.js.map +2 -2
  57. package/dist/esm/server/schema.js +67 -0
  58. package/dist/esm/server/schema.js.map +2 -2
  59. package/dist/esm-types/cli/codegen_templates/component_server.d.ts.map +1 -1
  60. package/dist/esm-types/cli/codegen_templates/server.d.ts +1 -0
  61. package/dist/esm-types/cli/codegen_templates/server.d.ts.map +1 -1
  62. package/dist/esm-types/cli/lib/login.d.ts +5 -0
  63. package/dist/esm-types/cli/lib/login.d.ts.map +1 -1
  64. package/dist/esm-types/cli/login.d.ts.map +1 -1
  65. package/dist/esm-types/cli/login.test.d.ts +2 -0
  66. package/dist/esm-types/cli/login.test.d.ts.map +1 -0
  67. package/dist/esm-types/index.d.ts +1 -1
  68. package/dist/esm-types/react/ConvexAuthState.d.ts.map +1 -1
  69. package/dist/esm-types/server/impl/actions_impl.d.ts.map +1 -1
  70. package/dist/esm-types/server/impl/actions_impl.test.d.ts +2 -0
  71. package/dist/esm-types/server/impl/actions_impl.test.d.ts.map +1 -0
  72. package/dist/esm-types/server/index.d.ts +2 -2
  73. package/dist/esm-types/server/index.d.ts.map +1 -1
  74. package/dist/esm-types/server/schema.d.ts +83 -5
  75. package/dist/esm-types/server/schema.d.ts.map +1 -1
  76. package/dist/react.bundle.js +31 -33
  77. package/dist/react.bundle.js.map +2 -2
  78. package/package.json +3 -4
  79. package/src/cli/codegen_templates/component_server.ts +12 -6
  80. package/src/cli/codegen_templates/server.ts +36 -19
  81. package/src/cli/lib/login.ts +20 -9
  82. package/src/cli/login.test.ts +261 -0
  83. package/src/cli/login.ts +88 -13
  84. package/src/index.ts +1 -1
  85. package/src/react/ConvexAuthState.tsx +15 -7
  86. package/src/server/impl/actions_impl.test.ts +59 -0
  87. package/src/server/impl/actions_impl.ts +23 -0
  88. package/src/server/index.ts +7 -1
  89. package/src/server/schema.test.ts +98 -0
  90. package/src/server/schema.ts +178 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "convex",
3
3
  "description": "Client for the Convex Cloud",
4
- "version": "1.43.0",
4
+ "version": "1.44.0",
5
5
  "author": "Convex, Inc. <no-reply@convex.dev>",
6
6
  "homepage": "https://convex.dev",
7
7
  "repository": {
@@ -258,19 +258,18 @@
258
258
  "openapi-typescript-helpers": "~0.1.0",
259
259
  "openid-client": "^5.3.1",
260
260
  "ora": "^9.0.0",
261
- "prettier": "3.8.1",
262
261
  "react": "^18.0.0",
263
262
  "react-dom": "^18.0.0",
264
263
  "semver": "^7.6.0",
265
264
  "serve-handler": "~6.1.6",
266
265
  "shx": "~0.4.0",
267
266
  "skott": "~0.35.4",
268
- "tar": "^7.5.16",
267
+ "tar": "^7.5.21",
269
268
  "tsx": "^4.19.4",
270
269
  "typedoc": "^0.24.6",
271
270
  "typescript": "~5.0.3",
272
271
  "typescript-eslint": "~8.57.0",
273
- "undici": "7.28.0",
272
+ "undici": "7.29.0",
274
273
  "vitest": "^4.1.0",
275
274
  "wait-for-expect": "~4.0.0",
276
275
  "zod": "^3.24.0",
@@ -1,15 +1,21 @@
1
1
  import { header } from "./common.js";
2
- import { EnvVarMeta, generateEnvInterface } from "./server.js";
2
+ import {
3
+ EnvVarMeta,
4
+ generateEnvInterface,
5
+ withPlatformEnvVars,
6
+ } from "./server.js";
3
7
 
4
8
  export function componentServerTS(
5
9
  isRoot: boolean,
6
10
  // - undefined: not yet known (initial codegen). Emits an untyped `env` stub.
7
- // - non-empty array: emit typed `Env` interface and typed `env` export.
8
- // - empty array: no env vars declared. Omit `env` from generated code.
11
+ // - array (empty or not): emit a typed `Env` interface and typed `env`
12
+ // export. The interface always includes the platform-provided env vars.
9
13
  envVars: EnvVarMeta[] | undefined,
10
14
  ): string {
11
- const hasEnv = !!envVars && envVars.length > 0;
12
- const envInterface = hasEnv ? generateEnvInterface(envVars!) : "";
15
+ const emittedEnvVars =
16
+ envVars === undefined ? undefined : withPlatformEnvVars(envVars);
17
+ const hasEnv = emittedEnvVars !== undefined;
18
+ const envInterface = hasEnv ? generateEnvInterface(emittedEnvVars!) : "";
13
19
 
14
20
  const result = `
15
21
  ${header(
@@ -113,7 +119,7 @@ export function componentServerTS(
113
119
  * @returns The wrapped function. Import this function from \`convex/http.js\` and route it to hook it up.
114
120
  */
115
121
  export const httpAction: HttpActionBuilder = httpActionGeneric;
116
- ${hasEnv ? "export const env: Env = process.env as unknown as Env;" : envVars === undefined ? "export const env: Record<string, string | undefined> = process.env as unknown as Record<string, string | undefined>;" : ""}
122
+ ${hasEnv ? "export const env: Env = (globalThis as unknown as { process: { env: Env } }).process.env;" : "export const env: Record<string, string | undefined> = (globalThis as unknown as { process: { env: Record<string, string | undefined> } }).process.env;"}
117
123
 
118
124
  /**
119
125
  * A set of services for use within Convex query functions.
@@ -6,8 +6,26 @@ export type EnvVarMeta = [
6
6
  { type: "value"; value: string; optional?: boolean | undefined },
7
7
  ];
8
8
 
9
+ // Environment variables the platform always injects into every deployment,
10
+ // independent of the user's `convex.config.ts` declarations. Both are always
11
+ // present, so they're typed as required `string`.
12
+ const PLATFORM_ENV_VARS: EnvVarMeta[] = [
13
+ ["CONVEX_CLOUD_URL", { type: "value", value: '{"type":"string"}' }],
14
+ ["CONVEX_SITE_URL", { type: "value", value: '{"type":"string"}' }],
15
+ ];
16
+
17
+ // Prepend the always-present platform env vars. Users can't declare env vars
18
+ // with these names — the backend rejects overriding built-in system env vars —
19
+ // so there's no collision to worry about.
20
+ export function withPlatformEnvVars(envVars: EnvVarMeta[]): EnvVarMeta[] {
21
+ return [...PLATFORM_ENV_VARS, ...envVars];
22
+ }
23
+
9
24
  const ENV_DOCSTRING = `/**
10
- * Typesafe environment variables declared in \`convex.config.ts\`.
25
+ * Typesafe environment variables.
26
+ *
27
+ * This includes platform-provided env vars and any variables declared in
28
+ * \`convex.config.ts\`.
11
29
  */`;
12
30
 
13
31
  export function serverCodegen({
@@ -17,42 +35,41 @@ export function serverCodegen({
17
35
  useTypeScript: boolean;
18
36
  // - undefined: env var declarations not yet known (initial codegen before
19
37
  // server analysis). Emits an untyped `env` stub so imports don't break.
20
- // - non-empty array: emit a typed `Env` interface and typed `env` export.
21
- // - empty array: no env vars declared. Omit `env` from generated code.
38
+ // - array (empty or not): emit a typed `Env` interface and typed `env`
39
+ // export. The interface always includes the platform-provided env vars, so
40
+ // an empty array still yields a typed `env` with just those.
22
41
  envVars: EnvVarMeta[] | undefined;
23
42
  }) {
24
- const hasEnv = !!envVars && envVars.length > 0;
25
- const envInterface = hasEnv ? generateEnvInterface(envVars!) : "";
43
+ const emittedEnvVars =
44
+ envVars === undefined ? undefined : withPlatformEnvVars(envVars);
45
+ const hasEnv = emittedEnvVars !== undefined;
46
+ const envInterface = hasEnv ? generateEnvInterface(emittedEnvVars!) : "";
26
47
  const envDeclDTS = hasEnv
27
48
  ? `
28
49
  ${ENV_DOCSTRING}
29
50
  export declare const env: Env;
30
51
  `
31
- : envVars === undefined
32
- ? `
52
+ : `
33
53
  export declare const env: Record<string, string | undefined>;
34
- `
35
- : "";
54
+ `;
55
+ // Route through `globalThis` rather than the bare `process` global so the
56
+ // generated file typechecks even in projects without `@types/node`.
36
57
  const envDeclTS = hasEnv
37
58
  ? `
38
59
  ${ENV_DOCSTRING}
39
- export const env: Env = process.env as unknown as Env;
60
+ export const env: Env = (globalThis as unknown as { process: { env: Env } }).process.env;
40
61
  `
41
- : envVars === undefined
42
- ? `
43
- export const env: Record<string, string | undefined> = process.env as unknown as Record<string, string | undefined>;
44
- `
45
- : "";
62
+ : `
63
+ export const env: Record<string, string | undefined> = (globalThis as unknown as { process: { env: Record<string, string | undefined> } }).process.env;
64
+ `;
46
65
  const envDeclJS = hasEnv
47
66
  ? `
48
67
  ${ENV_DOCSTRING}
49
68
  export const env = process.env;
50
69
  `
51
- : envVars === undefined
52
- ? `
70
+ : `
53
71
  export const env = process.env;
54
- `
55
- : "";
72
+ `;
56
73
 
57
74
  if (!useTypeScript) {
58
75
  // Generate separate .js and .d.ts files
@@ -38,14 +38,14 @@ custom.setHttpOptionsDefaults({
38
38
  timeout: parseInt(process.env.OPENID_CLIENT_TIMEOUT || "10000"),
39
39
  });
40
40
 
41
- export async function checkAuthorization(
41
+ /**
42
+ * Whether Big Brain accepts this credential. Takes the header explicitly so
43
+ * callers can check a credential other than the one `ctx` resolved to.
44
+ */
45
+ export async function isAuthorizedHeader(
42
46
  ctx: Context,
43
- acceptOptIns: boolean,
47
+ header: string,
44
48
  ): Promise<boolean> {
45
- const header = ctx.bigBrainAuth()?.header ?? null;
46
- if (header === null) {
47
- return false;
48
- }
49
49
  try {
50
50
  const resp = await fetch(`${provisionHost}/api/authorize`, {
51
51
  method: "HEAD",
@@ -57,9 +57,7 @@ export async function checkAuthorization(
57
57
  // Don't throw an error if this request returns a non-200 status.
58
58
  // Big Brain responds with a variety of error codes -- 401 if the token is correctly-formed but not valid, and either 400 or 500 if the token is ill-formed.
59
59
  // We only care if this check returns a 200 code (so we can skip logging in again) -- any other errors should be silently skipped and we'll run the whole login flow again.
60
- if (resp.status !== 200) {
61
- return false;
62
- }
60
+ return resp.status === 200;
63
61
  } catch (e: any) {
64
62
  // This `catch` block should only be hit if a network error was encountered
65
63
  logError(
@@ -67,6 +65,19 @@ export async function checkAuthorization(
67
65
  );
68
66
  return await logAndHandleFetchError(ctx, e);
69
67
  }
68
+ }
69
+
70
+ export async function checkAuthorization(
71
+ ctx: Context,
72
+ acceptOptIns: boolean,
73
+ ): Promise<boolean> {
74
+ const header = ctx.bigBrainAuth()?.header ?? null;
75
+ if (header === null) {
76
+ return false;
77
+ }
78
+ if (!(await isAuthorizedHeader(ctx, header))) {
79
+ return false;
80
+ }
70
81
 
71
82
  // Check that we have optin as well
72
83
  const shouldContinue = await optins(ctx, acceptOptIns);
@@ -0,0 +1,261 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { nodeFs } from "../bundler/fs.js";
3
+ import { login } from "./login.js";
4
+ import { bigBrainAPI } from "./lib/utils/utils.js";
5
+ import { readGlobalConfig } from "./lib/utils/globalConfig.js";
6
+
7
+ vi.mock("@sentry/node", () => ({
8
+ captureException: vi.fn(),
9
+ close: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("../bundler/fs.js", async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import("../bundler/fs.js")>();
14
+ return {
15
+ ...actual,
16
+ nodeFs: {
17
+ ...actual.nodeFs,
18
+ exists: vi.fn(),
19
+ readUtf8File: vi.fn(),
20
+ },
21
+ };
22
+ });
23
+
24
+ vi.mock("./lib/utils/utils.js", async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import("./lib/utils/utils.js")>();
26
+ return { ...actual, bigBrainAPI: vi.fn() };
27
+ });
28
+
29
+ vi.mock("./lib/utils/globalConfig.js", async (importOriginal) => {
30
+ const actual =
31
+ await importOriginal<typeof import("./lib/utils/globalConfig.js")>();
32
+ return { ...actual, readGlobalConfig: vi.fn() };
33
+ });
34
+
35
+ // `initializeBigBrainAuth` loads `.env.local` and `.env` into `process.env`.
36
+ // Tests set those variables directly, and `dotenv.parse` stays real for the
37
+ // file-attribution lookup.
38
+ vi.mock("dotenv", async (importOriginal) => {
39
+ const actual = await importOriginal<typeof import("dotenv")>();
40
+ return { ...actual, config: vi.fn() };
41
+ });
42
+
43
+ const ACCOUNT_TOKEN = "account-token";
44
+ const TEAMS = [{ id: 1, name: "Team One", slug: "team-one" }];
45
+ const DEPLOYMENT_KEY = "dev:tall-goat-123|deployment-secret";
46
+ const PROJECT_KEY = "project:my-team:my-project|project-secret";
47
+
48
+ /** Authorization headers Big Brain accepts. Anything else gets a 401. */
49
+ let authorized: Set<string>;
50
+ let mockFetch: ReturnType<typeof vi.fn>;
51
+
52
+ async function runStatus() {
53
+ await login.parseAsync(["status"], { from: "user" });
54
+ }
55
+
56
+ function output(): string {
57
+ return vi
58
+ .mocked(process.stderr.write)
59
+ .mock.calls.map((call) => String(call[0]))
60
+ .join("");
61
+ }
62
+
63
+ /** The credentials `/api/authorize` was called with, in order. */
64
+ function authorizeHeaders(): string[] {
65
+ return mockFetch.mock.calls.map(
66
+ (call) => (call[1] as any).headers.Authorization,
67
+ );
68
+ }
69
+
70
+ function setEnvFile(path: string, contents: string) {
71
+ vi.mocked(nodeFs.exists).mockImplementation((p) => p === path);
72
+ vi.mocked(nodeFs.readUtf8File).mockImplementation((p) => {
73
+ if (p !== path) {
74
+ throw new Error(`Unexpected read of ${p}`);
75
+ }
76
+ return contents;
77
+ });
78
+ }
79
+
80
+ describe("npx convex login status", () => {
81
+ let savedEnv: NodeJS.ProcessEnv;
82
+
83
+ beforeEach(() => {
84
+ savedEnv = { ...process.env };
85
+ process.env = {};
86
+
87
+ vi.resetAllMocks();
88
+ authorized = new Set([`Bearer ${ACCOUNT_TOKEN}`]);
89
+ mockFetch = vi.fn(async (url: string, options: any) => {
90
+ if (!String(url).endsWith("/api/authorize")) {
91
+ throw new Error(`Unexpected fetch of ${url}`);
92
+ }
93
+ return {
94
+ status: authorized.has(options.headers.Authorization) ? 200 : 401,
95
+ };
96
+ });
97
+ vi.stubGlobal("fetch", mockFetch);
98
+
99
+ vi.mocked(readGlobalConfig).mockReturnValue({ accessToken: ACCOUNT_TOKEN });
100
+ vi.mocked(bigBrainAPI).mockResolvedValue(TEAMS);
101
+ vi.mocked(nodeFs.exists).mockReturnValue(false);
102
+
103
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
104
+ vi.spyOn(process, "exit").mockImplementation((() => {
105
+ throw new Error("process.exit called");
106
+ }) as any);
107
+ });
108
+
109
+ afterEach(() => {
110
+ process.env = savedEnv;
111
+ vi.unstubAllGlobals();
112
+ vi.restoreAllMocks();
113
+ });
114
+
115
+ describe("account status", () => {
116
+ test("reports the account and its teams", async () => {
117
+ await runStatus();
118
+
119
+ expect(output()).toContain("Convex account token found in:");
120
+ expect(output()).toContain("config.json");
121
+ expect(output()).toContain("Status: Logged in");
122
+ expect(output()).toContain("Teams: 1 team accessible");
123
+ expect(output()).toContain("- Team One (team-one)");
124
+ expect(output()).not.toContain("Working Directory:");
125
+ });
126
+
127
+ test("reports no token when the global config is missing", async () => {
128
+ vi.mocked(readGlobalConfig).mockReturnValue(null);
129
+
130
+ await runStatus();
131
+
132
+ expect(output()).toContain("No Convex account token found in:");
133
+ expect(output()).toContain("Status: Not logged in");
134
+ expect(bigBrainAPI).not.toHaveBeenCalled();
135
+ });
136
+
137
+ test("reports not logged in when Big Brain rejects the account token", async () => {
138
+ authorized.clear();
139
+
140
+ await runStatus();
141
+
142
+ expect(output()).toContain("Convex account token found in:");
143
+ expect(output()).toContain("Status: Not logged in");
144
+ expect(bigBrainAPI).not.toHaveBeenCalled();
145
+ });
146
+ });
147
+
148
+ describe("deploy key in the working directory", () => {
149
+ test("reports a valid deployment key, checked without its prefix", async () => {
150
+ process.env.CONVEX_DEPLOY_KEY = DEPLOYMENT_KEY;
151
+ setEnvFile(".env.local", `CONVEX_DEPLOY_KEY=${DEPLOYMENT_KEY}\n`);
152
+ // Big Brain only accepts a deployment key with the `dev:` prefix removed.
153
+ authorized.add("Bearer deployment-secret");
154
+
155
+ await runStatus();
156
+
157
+ expect(output()).toContain(
158
+ "Working Directory: Valid CONVEX_DEPLOY_KEY in .env.local",
159
+ );
160
+ expect(authorizeHeaders()).toContain("Bearer deployment-secret");
161
+ expect(authorizeHeaders()).not.toContain(`Bearer ${DEPLOYMENT_KEY}`);
162
+ });
163
+
164
+ test("reports a deployment key Big Brain rejects", async () => {
165
+ process.env.CONVEX_DEPLOY_KEY = DEPLOYMENT_KEY;
166
+ setEnvFile(".env.local", `CONVEX_DEPLOY_KEY=${DEPLOYMENT_KEY}\n`);
167
+
168
+ await runStatus();
169
+
170
+ expect(output()).toContain(
171
+ "Working Directory: Invalid CONVEX_DEPLOY_KEY in .env.local",
172
+ );
173
+ });
174
+
175
+ test("attributes a key that isn't in an env file to the shell", async () => {
176
+ process.env.CONVEX_DEPLOY_KEY = DEPLOYMENT_KEY;
177
+
178
+ await runStatus();
179
+
180
+ expect(output()).toContain(
181
+ "Working Directory: Invalid CONVEX_DEPLOY_KEY in shell environment",
182
+ );
183
+ });
184
+
185
+ test("attributes a key found in .env", async () => {
186
+ process.env.CONVEX_DEPLOY_KEY = DEPLOYMENT_KEY;
187
+ setEnvFile(".env", `CONVEX_DEPLOY_KEY=${DEPLOYMENT_KEY}\n`);
188
+
189
+ await runStatus();
190
+
191
+ expect(output()).toContain(
192
+ "Working Directory: Invalid CONVEX_DEPLOY_KEY in .env",
193
+ );
194
+ });
195
+
196
+ test("names CONVEX_DEPLOYMENT_TOKEN when that's the variable set", async () => {
197
+ process.env.CONVEX_DEPLOYMENT_TOKEN = DEPLOYMENT_KEY;
198
+ setEnvFile(".env.local", `CONVEX_DEPLOYMENT_TOKEN=${DEPLOYMENT_KEY}\n`);
199
+ authorized.add("Bearer deployment-secret");
200
+
201
+ await runStatus();
202
+
203
+ expect(output()).toContain(
204
+ "Working Directory: Valid CONVEX_DEPLOYMENT_TOKEN in .env.local",
205
+ );
206
+ });
207
+
208
+ test("reports a project key", async () => {
209
+ process.env.CONVEX_DEPLOY_KEY = PROJECT_KEY;
210
+ setEnvFile(".env.local", `CONVEX_DEPLOY_KEY=${PROJECT_KEY}\n`);
211
+ authorized.add("Bearer project-secret");
212
+
213
+ await runStatus();
214
+
215
+ expect(output()).toContain(
216
+ "Working Directory: Valid CONVEX_DEPLOY_KEY in .env.local",
217
+ );
218
+ expect(authorizeHeaders()).toContain("Bearer project-secret");
219
+ });
220
+
221
+ test("stays quiet about a preview key, which never outranks the account", async () => {
222
+ vi.mocked(readGlobalConfig).mockReturnValue(null);
223
+ process.env.CONVEX_DEPLOY_KEY =
224
+ "preview:my-team:my-project|preview-secret";
225
+
226
+ await runStatus();
227
+
228
+ expect(output()).toContain("Status: Not logged in");
229
+ expect(output()).not.toContain("Working Directory:");
230
+ });
231
+ });
232
+
233
+ describe("account and deploy key together", () => {
234
+ test("reports both, listing teams for the account", async () => {
235
+ process.env.CONVEX_DEPLOY_KEY = DEPLOYMENT_KEY;
236
+ setEnvFile(".env.local", `CONVEX_DEPLOY_KEY=${DEPLOYMENT_KEY}\n`);
237
+
238
+ await runStatus();
239
+
240
+ expect(output()).toContain("Status: Logged in");
241
+ expect(output()).toContain("Teams: 1 team accessible");
242
+ expect(output()).toContain(
243
+ "Working Directory: Invalid CONVEX_DEPLOY_KEY in .env.local",
244
+ );
245
+ });
246
+
247
+ test("reports the key while logged out", async () => {
248
+ vi.mocked(readGlobalConfig).mockReturnValue(null);
249
+ process.env.CONVEX_DEPLOY_KEY = DEPLOYMENT_KEY;
250
+ setEnvFile(".env.local", `CONVEX_DEPLOY_KEY=${DEPLOYMENT_KEY}\n`);
251
+
252
+ await runStatus();
253
+
254
+ expect(output()).toContain("No Convex account token found in:");
255
+ expect(output()).toContain("Status: Not logged in");
256
+ expect(output()).toContain(
257
+ "Working Directory: Invalid CONVEX_DEPLOY_KEY in .env.local",
258
+ );
259
+ });
260
+ });
261
+ });
package/src/cli/login.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { Command, Option } from "@commander-js/extra-typings";
2
- import { Context, oneoffContext } from "../bundler/context.js";
3
- import { logFinishedStep, logMessage } from "../bundler/log.js";
4
- import { checkAuthorization, performLogin } from "./lib/login.js";
2
+ import * as dotenv from "dotenv";
3
+ import { BigBrainAuth, Context, oneoffContext } from "../bundler/context.js";
4
+ import { logFinishedStep, logMessage, logWarning } from "../bundler/log.js";
5
+ import {
6
+ checkAuthorization,
7
+ isAuthorizedHeader,
8
+ performLogin,
9
+ } from "./lib/login.js";
5
10
  import {
6
11
  loadProjectLocalConfig,
7
12
  loadUuidForAnonymousUser,
@@ -16,7 +21,12 @@ import {
16
21
  teamDashboardUrl,
17
22
  } from "./lib/dashboard.js";
18
23
  import { promptSearch, promptYesNo } from "./lib/utils/prompts.js";
19
- import { validateOrSelectTeam } from "./lib/utils/utils.js";
24
+ import {
25
+ CONVEX_DEPLOY_KEY_ENV_VAR_NAME,
26
+ CONVEX_DEPLOYMENT_TOKEN_ENV_VAR_NAME,
27
+ ENV_VAR_FILE_PATH,
28
+ validateOrSelectTeam,
29
+ } from "./lib/utils/utils.js";
20
30
  import {
21
31
  selectProject,
22
32
  updateEnvAndConfigForDeploymentSelection,
@@ -35,6 +45,61 @@ import {
35
45
  } from "./lib/utils/globalConfig.js";
36
46
  import { getTeamsForUser } from "./lib/api.js";
37
47
 
48
+ /**
49
+ * The env file a deploy key came from, or null if it came from the shell.
50
+ * `dotenv` doesn't overwrite variables that are already set, so a value from
51
+ * the shell outranks these files -- match on the value to tell them apart.
52
+ */
53
+ function deployKeyEnvFile(
54
+ ctx: Context,
55
+ envVarName: string,
56
+ value: string,
57
+ ): string | null {
58
+ for (const file of [ENV_VAR_FILE_PATH, ".env"]) {
59
+ if (!ctx.fs.exists(file)) {
60
+ continue;
61
+ }
62
+ if (dotenv.parse(ctx.fs.readUtf8File(file))[envVarName] === value) {
63
+ return file;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * A project or deployment key in the environment outranks the account token for
71
+ * commands run from this directory, and an expired one makes the CLI look
72
+ * logged out, so report it separately from the account.
73
+ */
74
+ async function reportDeployKeyInWorkingDirectory(
75
+ ctx: Context,
76
+ auth: BigBrainAuth | null,
77
+ ) {
78
+ // A preview deploy key is only used when there's no account token at all.
79
+ if (
80
+ auth === null ||
81
+ auth.kind === "accessToken" ||
82
+ auth.kind === "previewDeployKey"
83
+ ) {
84
+ return;
85
+ }
86
+ const key = auth.kind === "projectKey" ? auth.projectKey : auth.deploymentKey;
87
+ const envVarName = process.env[CONVEX_DEPLOY_KEY_ENV_VAR_NAME]
88
+ ? CONVEX_DEPLOY_KEY_ENV_VAR_NAME
89
+ : CONVEX_DEPLOYMENT_TOKEN_ENV_VAR_NAME;
90
+ const envFile = deployKeyEnvFile(ctx, envVarName, key);
91
+ const source = envFile ?? "shell environment";
92
+ // Big Brain only strips a `project:`/`team:` prefix itself, so a deployment
93
+ // key authorizes only without its `dev:`/`prod:` prefix. Split on the first
94
+ // `|` like the server does, so a secret containing one survives.
95
+ const secret = key.slice(key.indexOf("|") + 1);
96
+ if (await isAuthorizedHeader(ctx, `Bearer ${secret}`)) {
97
+ logMessage(`Working Directory: Valid ${envVarName} in ${source}`);
98
+ return;
99
+ }
100
+ logWarning(`Working Directory: Invalid ${envVarName} in ${source}`);
101
+ }
102
+
38
103
  const loginStatus = new Command("status")
39
104
  .description("Check login status and list accessible teams")
40
105
  .allowExcessArguments(false)
@@ -45,24 +110,33 @@ const loginStatus = new Command("status")
45
110
  envFile: undefined,
46
111
  });
47
112
 
48
- const globalConfig = readGlobalConfig(ctx);
49
- const hasToken = globalConfig?.accessToken !== null;
113
+ // `_updateBigBrainAuth` below replaces any deploy key in `ctx` with the
114
+ // account token, so read the deploy key first.
115
+ const auth = ctx.bigBrainAuth();
50
116
 
51
- if (hasToken) {
52
- logMessage(`Convex account token found in: ${globalConfigPath()}`);
53
- } else {
54
- logMessage("No token found locally");
117
+ const globalConfig = readGlobalConfig(ctx);
118
+ if (globalConfig === null) {
119
+ logMessage(`No Convex account token found in: ${globalConfigPath()}`);
120
+ logMessage("Status: Not logged in");
121
+ await reportDeployKeyInWorkingDirectory(ctx, auth);
55
122
  return;
56
123
  }
124
+ logMessage(`Convex account token found in: ${globalConfigPath()}`);
57
125
 
58
- const isLoggedIn = await checkAuthorization(ctx, false);
59
-
60
- if (!isLoggedIn) {
126
+ const accessToken = globalConfig.accessToken;
127
+ if (!(await isAuthorizedHeader(ctx, `Bearer ${accessToken}`))) {
61
128
  logMessage("Status: Not logged in");
129
+ await reportDeployKeyInWorkingDirectory(ctx, auth);
62
130
  return;
63
131
  }
64
132
 
65
133
  logMessage("Status: Logged in");
134
+ // Describe the account, even if a deploy key outranks it for other commands.
135
+ ctx._updateBigBrainAuth({
136
+ kind: "accessToken",
137
+ header: `Bearer ${accessToken}`,
138
+ accessToken,
139
+ });
66
140
  const teams = await getTeamsForUser(ctx);
67
141
  logMessage(
68
142
  `Teams: ${teams.length} team${teams.length === 1 ? "" : "s"} accessible`,
@@ -70,6 +144,7 @@ const loginStatus = new Command("status")
70
144
  for (const team of teams) {
71
145
  logMessage(` - ${team.name} (${team.slug})`);
72
146
  }
147
+ await reportDeployKeyInWorkingDirectory(ctx, auth);
73
148
  });
74
149
 
75
150
  export const login = new Command("login")
package/src/index.ts CHANGED
@@ -1 +1 @@
1
- export const version = "1.43.0";
1
+ export const version = "1.44.0";
@@ -3,6 +3,7 @@ import React, {
3
3
  ReactNode,
4
4
  useContext,
5
5
  useEffect,
6
+ useMemo,
6
7
  useState,
7
8
  } from "react";
8
9
  import { AuthTokenFetcher } from "../browser/sync/client.js";
@@ -129,15 +130,22 @@ export function ConvexProviderWithAuth({
129
130
 
130
131
  const isAuthenticated =
131
132
  authProviderAuthenticated && (isConvexAuthenticated ?? false);
133
+ const isLoading = isConvexAuthenticated === null;
134
+ const isRefreshingWhileAuthenticated = isRefreshing && isAuthenticated;
135
+
136
+ // Stabilize the context value's identity so `useConvexAuth()` consumers
137
+ // only re-render when the derived auth state actually changes.
138
+ const authState = useMemo<ConvexAuthState>(
139
+ () => ({
140
+ isLoading,
141
+ isAuthenticated,
142
+ isRefreshing: isRefreshingWhileAuthenticated,
143
+ }),
144
+ [isLoading, isAuthenticated, isRefreshingWhileAuthenticated],
145
+ );
132
146
 
133
147
  return (
134
- <ConvexAuthContext.Provider
135
- value={{
136
- isLoading: isConvexAuthenticated === null,
137
- isAuthenticated,
138
- isRefreshing: isRefreshing && isAuthenticated,
139
- }}
140
- >
148
+ <ConvexAuthContext.Provider value={authState}>
141
149
  <ConvexAuthStateFirstEffect
142
150
  authProviderAuthenticated={authProviderAuthenticated}
143
151
  fetchAccessToken={fetchAccessToken}