@ollie-shop/cli 1.2.2 → 1.4.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.
package/src/cli.tsx CHANGED
@@ -3,6 +3,9 @@ import { HelpCommand } from "./commands/help.js";
3
3
  import { LoginCommand } from "./commands/login.js";
4
4
  import { StartCommand } from "./commands/start.js";
5
5
 
6
+ // Inlined by tsup at build time from package.json (see tsup.config.ts).
7
+ declare const __OLLIE_CLI_VERSION__: string;
8
+
6
9
  interface AppProps {
7
10
  command: string;
8
11
  args: string[];
@@ -28,9 +31,13 @@ export function App({ command, args }: AppProps) {
28
31
  }
29
32
 
30
33
  function VersionCommand() {
34
+ const version =
35
+ typeof __OLLIE_CLI_VERSION__ === "string" && __OLLIE_CLI_VERSION__
36
+ ? __OLLIE_CLI_VERSION__
37
+ : "unknown";
31
38
  return (
32
39
  <Box>
33
- <Text>ollie v0.1.0</Text>
40
+ <Text>ollieshop v{version}</Text>
34
41
  </Box>
35
42
  );
36
43
  }
@@ -17,6 +17,43 @@ import {
17
17
 
18
18
  const ON_ERROR_VALUES = ["throw", "skip"] as const;
19
19
 
20
+ function parseTriggerFromFlags(
21
+ urlFlag: string | undefined,
22
+ expressionFlag: string | undefined,
23
+ ): { url: string; expression: string } | undefined {
24
+ if (urlFlag === undefined && expressionFlag === undefined) return undefined;
25
+ if (urlFlag === undefined || expressionFlag === undefined) {
26
+ throw new Error(
27
+ "Both --trigger-url and --trigger-expression are required when configuring a trigger.",
28
+ );
29
+ }
30
+ return {
31
+ url: validateTriggerUrl(urlFlag, "trigger-url"),
32
+ expression: validateRequired(expressionFlag, "trigger-expression"),
33
+ };
34
+ }
35
+
36
+ function parseTriggerFromData(
37
+ raw: unknown,
38
+ ): { url: string; expression: string } | undefined {
39
+ if (raw === undefined || raw === null) return undefined;
40
+ if (typeof raw !== "object" || Array.isArray(raw)) {
41
+ throw new Error(
42
+ 'Invalid trigger: must be an object with "url" and "expression" string properties.',
43
+ );
44
+ }
45
+ const obj = raw as Record<string, unknown>;
46
+ if (typeof obj.url !== "string" || typeof obj.expression !== "string") {
47
+ throw new Error(
48
+ 'Invalid trigger: both "url" and "expression" must be non-empty strings.',
49
+ );
50
+ }
51
+ return {
52
+ url: validateTriggerUrl(obj.url, "trigger.url"),
53
+ expression: validateRequired(obj.expression, "trigger.expression"),
54
+ };
55
+ }
56
+
20
57
  export async function functionCommand(parsed: ParsedArgs): Promise<void> {
21
58
  const sub = parsed.subcommand;
22
59
  if (sub === "create") return functionCreateCommand(parsed);
@@ -35,7 +72,7 @@ async function functionCreateCommand(parsed: ParsedArgs): Promise<void> {
35
72
  let input: {
36
73
  versionId: string;
37
74
  name: string;
38
- trigger?: string;
75
+ trigger?: { url: string; expression: string };
39
76
  active?: boolean;
40
77
  onError?: "throw" | "skip";
41
78
  priority?: number;
@@ -46,10 +83,7 @@ async function functionCreateCommand(parsed: ParsedArgs): Promise<void> {
46
83
  input = {
47
84
  versionId: validateUuid(raw.versionId, "versionId"),
48
85
  name: validateRequired(raw.name, "name"),
49
- trigger:
50
- raw.trigger !== undefined && raw.trigger !== null
51
- ? validateTriggerUrl(String(raw.trigger), "trigger")
52
- : undefined,
86
+ trigger: parseTriggerFromData(raw.trigger),
53
87
  active: raw.active === true,
54
88
  onError:
55
89
  raw.onError !== undefined && raw.onError !== null
@@ -61,7 +95,8 @@ async function functionCreateCommand(parsed: ParsedArgs): Promise<void> {
61
95
  : 0,
62
96
  };
63
97
  } else {
64
- const triggerFlag = getFlag(parsed.flags, "trigger");
98
+ const triggerUrlFlag = getFlag(parsed.flags, "trigger-url");
99
+ const triggerExpressionFlag = getFlag(parsed.flags, "trigger-expression");
65
100
  const onErrorFlag = getFlag(parsed.flags, "on-error");
66
101
  const priorityFlag = getFlag(parsed.flags, "priority");
67
102
  input = {
@@ -70,10 +105,7 @@ async function functionCreateCommand(parsed: ParsedArgs): Promise<void> {
70
105
  "version-id",
71
106
  ),
72
107
  name: validateRequired(getFlag(parsed.flags, "name", "n"), "name"),
73
- trigger:
74
- triggerFlag !== undefined
75
- ? validateTriggerUrl(triggerFlag, "trigger")
76
- : undefined,
108
+ trigger: parseTriggerFromFlags(triggerUrlFlag, triggerExpressionFlag),
77
109
  active: getBoolFlag(parsed.flags, "active"),
78
110
  onError:
79
111
  onErrorFlag !== undefined
@@ -41,7 +41,7 @@ export function HelpCommand() {
41
41
  <Box width={24}>
42
42
  <Text color="green">whoami</Text>
43
43
  </Box>
44
- <Text>Show current user and organization</Text>
44
+ <Text>Show current user (and linked store/org from ollie.json)</Text>
45
45
  </Box>
46
46
  <Box>
47
47
  <Box width={24}>
@@ -139,6 +139,12 @@ export function HelpCommand() {
139
139
  </Box>
140
140
  <Text>Config stage (loads ollie.{"{stage}"}.json)</Text>
141
141
  </Box>
142
+ <Box>
143
+ <Box width={24}>
144
+ <Text color="yellow">--no-open</Text>
145
+ </Box>
146
+ <Text>start: don't auto-open Studio (also honored via CI env)</Text>
147
+ </Box>
142
148
  </Box>
143
149
 
144
150
  <Box marginTop={1}>
@@ -147,12 +153,17 @@ export function HelpCommand() {
147
153
  <Box marginLeft={2} flexDirection="column">
148
154
  <Text dimColor>$ ollieshop login</Text>
149
155
  <Text dimColor>$ ollieshop start --stage dev</Text>
156
+ <Text dimColor>$ ollieshop start --no-open</Text>
150
157
  <Text dimColor>$ ollieshop whoami -o json</Text>
151
158
  <Text dimColor>$ ollieshop schema store.create</Text>
152
159
  <Text dimColor>
153
160
  $ ollieshop store create --name "My Store" --platform vtex
154
161
  --platform-store-id mystore
155
162
  </Text>
163
+ <Text dimColor>
164
+ $ ollieshop store create --org UUID --name "My Store" --platform vtex
165
+ --platform-store-id mystore
166
+ </Text>
156
167
  <Text dimColor>
157
168
  $ ollieshop store create --data{" "}
158
169
  {
@@ -1,11 +1,10 @@
1
- import type { BuildContext, ServeOnRequestArgs } from "esbuild";
1
+ import type { ServeOnRequestArgs } from "esbuild";
2
2
  import { Box, Text, useApp, useInput } from "ink";
3
3
  import open from "open";
4
4
  import { useCallback, useEffect, useRef, useState } from "react";
5
5
  import { loadConfig, resolveStage } from "../utils/config.js";
6
6
  import {
7
7
  type ComponentInfo,
8
- createBuildContext,
9
8
  discoverComponents,
10
9
  startDevServer,
11
10
  } from "../utils/esbuild.js";
@@ -55,11 +54,20 @@ export function StartCommand({ args }: StartCommandProps) {
55
54
  const [buildCount, setBuildCount] = useState(0);
56
55
  const [lastBuildTime, setLastBuildTime] = useState<Date | null>(null);
57
56
  const logIdRef = useRef(0);
58
- const ctxRef = useRef<BuildContext | null>(null);
57
+ const rebuildRef = useRef<(() => Promise<void>) | null>(null);
59
58
  const stopRef = useRef<(() => Promise<void>) | null>(null);
60
59
 
61
60
  // Parse args
62
61
  const stage = resolveStage(parseArg(args, "--stage", "-s"));
62
+ // Suppress auto-opening Studio in the browser (Storybook-style). Useful when an
63
+ // agent spawns the dev server and drives its own harness instead. Also honored
64
+ // via the conventional CI env var.
65
+ const noOpen = args.includes("--no-open") || Boolean(process.env.CI);
66
+ // Keyboard shortcuts need raw mode, which Ink can only enable on a TTY stdin.
67
+ // When spawned headless (agent/CI/backgrounded), stdin isn't a TTY — guard the
68
+ // input handler so the server runs instead of crashing with "Raw mode is not
69
+ // supported on the current process.stdin".
70
+ const isInteractive = Boolean(process.stdin.isTTY);
63
71
 
64
72
  const addLog = useCallback((log: Omit<RequestLog, "id" | "timestamp">) => {
65
73
  setLogs((prev) => {
@@ -121,28 +129,19 @@ export function StartCommand({ args }: StartCommandProps) {
121
129
  setComponents(found);
122
130
  setState({ status: "building" });
123
131
 
124
- // Create build context with discovered components
125
- const ctx = await createBuildContext(found, {
132
+ // Start dev server
133
+ const server = await startDevServer({
134
+ port: PORT,
126
135
  stage,
136
+ onRequest: handleRequest,
127
137
  onBuildEnd: (updatedComponents) => {
128
138
  setComponents(updatedComponents);
129
139
  setBuildCount((c) => c + 1);
130
140
  setLastBuildTime(new Date());
131
141
  },
132
142
  });
133
- ctxRef.current = ctx;
134
-
135
- // Do initial build (manifest is written by the plugin)
136
- await ctx.rebuild();
137
-
138
- if (!mounted) return;
139
-
140
- // Start dev server
141
- const server = await startDevServer(ctx, {
142
- port: PORT,
143
- onRequest: handleRequest,
144
- });
145
143
 
144
+ rebuildRef.current = server.rebuild;
146
145
  stopRef.current = server.stop;
147
146
 
148
147
  if (!mounted) {
@@ -158,13 +157,15 @@ export function StartCommand({ args }: StartCommandProps) {
158
157
  versionId: config.versionId,
159
158
  });
160
159
 
161
- // Open Studio in browser
162
- const studioUrl = new URL(STUDIO_BASE_URL);
163
- studioUrl.searchParams.set("storeId", config.storeId);
164
- if (config.versionId) {
165
- studioUrl.searchParams.set("versionId", config.versionId);
160
+ // Open Studio in browser (unless suppressed)
161
+ if (!noOpen) {
162
+ const studioUrl = new URL(STUDIO_BASE_URL);
163
+ studioUrl.searchParams.set("storeId", config.storeId);
164
+ if (config.versionId) {
165
+ studioUrl.searchParams.set("versionId", config.versionId);
166
+ }
167
+ open(studioUrl.toString());
166
168
  }
167
- open(studioUrl.toString());
168
169
  } catch (error) {
169
170
  if (!mounted) return;
170
171
  setState({
@@ -180,29 +181,32 @@ export function StartCommand({ args }: StartCommandProps) {
180
181
  mounted = false;
181
182
  stopRef.current?.();
182
183
  };
183
- }, [stage, handleRequest]);
184
+ }, [stage, handleRequest, noOpen]);
184
185
 
185
- // Handle keyboard input
186
- useInput((input, key) => {
187
- if (input === "q" || (input === "c" && key.ctrl)) {
188
- stopRef.current?.().then(() => exit());
189
- }
186
+ // Handle keyboard input (only when attached to a TTY — see isInteractive above)
187
+ useInput(
188
+ (input, key) => {
189
+ if (input === "q" || (input === "c" && key.ctrl)) {
190
+ stopRef.current?.().then(() => exit());
191
+ }
190
192
 
191
- // Manual rebuild with 'r' (manifest is updated by the plugin)
192
- if (input === "r" && state.status === "running") {
193
- ctxRef.current?.rebuild();
194
- }
193
+ // Manual rebuild with 'r' (manifest is updated by the plugin)
194
+ if (input === "r" && state.status === "running") {
195
+ rebuildRef.current?.();
196
+ }
195
197
 
196
- // Open Studio in browser with 'o'
197
- if (input === "o" && state.status === "running") {
198
- const studioUrl = new URL(STUDIO_BASE_URL);
199
- studioUrl.searchParams.set("storeId", state.storeId);
200
- if (state.versionId) {
201
- studioUrl.searchParams.set("versionId", state.versionId);
198
+ // Open Studio in browser with 'o'
199
+ if (input === "o" && state.status === "running") {
200
+ const studioUrl = new URL(STUDIO_BASE_URL);
201
+ studioUrl.searchParams.set("storeId", state.storeId);
202
+ if (state.versionId) {
203
+ studioUrl.searchParams.set("versionId", state.versionId);
204
+ }
205
+ open(studioUrl.toString());
202
206
  }
203
- open(studioUrl.toString());
204
- }
205
- });
207
+ },
208
+ { isActive: isInteractive },
209
+ );
206
210
 
207
211
  return (
208
212
  <Box flexDirection="column" gap={1}>
@@ -246,11 +250,12 @@ export function StartCommand({ args }: StartCommandProps) {
246
250
  stage={stage}
247
251
  storeId={state.storeId}
248
252
  versionId={state.versionId}
253
+ noOpen={noOpen}
249
254
  />
250
255
  <ComponentList components={components} />
251
256
  <BuildInfo buildCount={buildCount} lastBuildTime={lastBuildTime} />
252
257
  <RequestLogs logs={logs} />
253
- <Footer />
258
+ <Footer interactive={isInteractive} />
254
259
  </>
255
260
  )}
256
261
  </Box>
@@ -274,12 +279,14 @@ function ServerInfo({
274
279
  stage,
275
280
  storeId,
276
281
  versionId,
282
+ noOpen,
277
283
  }: {
278
284
  host: string;
279
285
  port: number;
280
286
  stage?: string;
281
287
  storeId: string;
282
288
  versionId?: string;
289
+ noOpen?: boolean;
283
290
  }) {
284
291
  const studioUrl = new URL(STUDIO_BASE_URL);
285
292
 
@@ -311,6 +318,7 @@ function ServerInfo({
311
318
  <Text bold color="magenta">
312
319
  {studioUrl.toString()}
313
320
  </Text>
321
+ {noOpen && <Text dimColor> (auto-open off — press o)</Text>}
314
322
  </Box>
315
323
  <Box marginLeft={2} marginTop={1}>
316
324
  <Text dimColor>
@@ -396,7 +404,15 @@ function RequestLogs({ logs }: { logs: RequestLog[] }) {
396
404
  );
397
405
  }
398
406
 
399
- function Footer() {
407
+ function Footer({ interactive = true }: { interactive?: boolean }) {
408
+ if (!interactive) {
409
+ return (
410
+ <Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
411
+ <Text dimColor>Headless (no TTY) — Ctrl+C to stop</Text>
412
+ </Box>
413
+ );
414
+ }
415
+
400
416
  return (
401
417
  <Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
402
418
  <Text dimColor>Press </Text>
@@ -7,7 +7,11 @@ import {
7
7
  } from "../utils/output.js";
8
8
  import { type ParsedArgs, getFlag } from "../utils/parse-args.js";
9
9
  import { getAuthenticatedClient } from "../utils/supabase.js";
10
- import { validateEnum, validateRequired } from "../utils/validate.js";
10
+ import {
11
+ validateEnum,
12
+ validateRequired,
13
+ validateUuid,
14
+ } from "../utils/validate.js";
11
15
 
12
16
  export async function storeCommand(parsed: ParsedArgs): Promise<void> {
13
17
  const sub = parsed.subcommand;
@@ -63,13 +67,16 @@ async function storeCreateCommand(parsed: ParsedArgs): Promise<void> {
63
67
  };
64
68
  }
65
69
 
70
+ const orgFlag = getFlag(parsed.flags, "org");
71
+ const orgId = orgFlag ? validateUuid(orgFlag, "org") : undefined;
72
+
66
73
  if (parsed.global.dryRun) {
67
- outputDryRun("store.create", input, format);
74
+ outputDryRun("store.create", { ...input, orgId }, format);
68
75
  return;
69
76
  }
70
77
 
71
78
  const client = await getAuthenticatedClient();
72
- const result = await createStore(client, input);
79
+ const result = await createStore(client, input, orgId);
73
80
 
74
81
  outputResult(result, format, parsed.global.fields);
75
82
  if (result.error) process.exit(1);
@@ -88,8 +95,11 @@ async function storeListCommand(parsed: ParsedArgs): Promise<void> {
88
95
  const format = detectOutputFormat(parsed.global.output);
89
96
 
90
97
  try {
98
+ const orgFlag = getFlag(parsed.flags, "org");
99
+ const orgId = orgFlag ? validateUuid(orgFlag, "org") : undefined;
100
+
91
101
  const client = await getAuthenticatedClient();
92
- const result = await listStores(client);
102
+ const result = await listStores(client, orgId);
93
103
 
94
104
  outputResult(result, format, parsed.global.fields);
95
105
  if (result.error) process.exit(1);
@@ -1,6 +1,9 @@
1
+ import { getStoreById } from "../core/store.js";
1
2
  import { getCurrentUser } from "../utils/auth.js";
3
+ import { loadConfig, resolveStage } from "../utils/config.js";
2
4
  import { detectOutputFormat, outputResult } from "../utils/output.js";
3
5
  import type { ParsedArgs } from "../utils/parse-args.js";
6
+ import { getAuthenticatedClient } from "../utils/supabase.js";
4
7
 
5
8
  export async function whoamiCommand(parsed: ParsedArgs): Promise<void> {
6
9
  const format = detectOutputFormat(parsed.global.output);
@@ -9,20 +12,78 @@ export async function whoamiCommand(parsed: ParsedArgs): Promise<void> {
9
12
  if (!user) {
10
13
  outputResult(
11
14
  {
12
- error: { message: "Not logged in. Run `ollieshop login`." },
15
+ error: {
16
+ message: "Not logged in or session expired. Run `ollieshop login`.",
17
+ },
13
18
  },
14
19
  format,
15
20
  );
16
21
  process.exit(1);
17
22
  }
18
23
 
19
- outputResult(
20
- {
21
- data: {
22
- email: user.email,
24
+ try {
25
+ const stage = resolveStage(parsed.global.stage);
26
+ const config = await loadConfig({ stage });
27
+
28
+ // No project config: just report the authenticated user.
29
+ if (!config?.storeId) {
30
+ outputResult(
31
+ {
32
+ data: {
33
+ email: user.email,
34
+ hint: "No ollie.json found. Run `ollieshop init` to link a store.",
35
+ },
36
+ },
37
+ format,
38
+ parsed.global.fields,
39
+ );
40
+ return;
41
+ }
42
+
43
+ // Config present: resolve the store/org it points at, gated by access.
44
+ const client = await getAuthenticatedClient();
45
+ const result = await getStoreById(client, config.storeId);
46
+
47
+ if (result.error || !result.data) {
48
+ outputResult(
49
+ {
50
+ data: {
51
+ email: user.email,
52
+ storeId: config.storeId,
53
+ store: null,
54
+ note:
55
+ result.error?.message ??
56
+ "Store not found. Run `ollieshop login` or check the storeId in ollie.json.",
57
+ },
58
+ },
59
+ format,
60
+ parsed.global.fields,
61
+ );
62
+ return;
63
+ }
64
+
65
+ const store = result.data;
66
+ outputResult(
67
+ {
68
+ data: {
69
+ email: user.email,
70
+ orgId: store.organizationId,
71
+ org: store.organizationName,
72
+ storeId: store.id,
73
+ store: store.name,
74
+ ...(config.versionId ? { versionId: config.versionId } : {}),
75
+ },
23
76
  },
24
- },
25
- format,
26
- parsed.global.fields,
27
- );
77
+ format,
78
+ parsed.global.fields,
79
+ );
80
+ } catch (err) {
81
+ outputResult(
82
+ {
83
+ error: { message: err instanceof Error ? err.message : String(err) },
84
+ },
85
+ format,
86
+ );
87
+ process.exit(1);
88
+ }
28
89
  }
@@ -4,7 +4,7 @@ import { functionCreateSchema } from "./schema.js";
4
4
  export interface CreateFunctionInput {
5
5
  versionId: string;
6
6
  name: string;
7
- trigger?: string;
7
+ trigger?: { url: string; expression: string };
8
8
  active?: boolean;
9
9
  onError?: "throw" | "skip";
10
10
  priority?: number;
@@ -65,11 +65,16 @@ export const functionCreateSchema = z.object({
65
65
  versionId: z.string().uuid().describe("Parent version UUID"),
66
66
  name: z.string().min(1).describe("Function name"),
67
67
  trigger: z
68
- .string()
69
- .min(1)
68
+ .object({
69
+ url: z.string().min(1).describe("Trigger URL — absolute http(s) URL"),
70
+ expression: z
71
+ .string()
72
+ .min(1)
73
+ .describe("JSONata-like match expression (e.g. 'method in [\"GET\"]')"),
74
+ })
70
75
  .optional()
71
76
  .describe(
72
- "Function trigger URL absolute http(s) URL or relative path starting with /",
77
+ "Function trigger — both url and expression are required together",
73
78
  ),
74
79
  active: z
75
80
  .boolean()
package/src/core/store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
- import { getOrganizationId } from "../utils/supabase.js";
2
+ import { getOrganizationId, unwrapToOne } from "../utils/supabase.js";
3
3
  import { storeCreateSchema } from "./schema.js";
4
4
 
5
5
  export interface CreateStoreInput {
@@ -24,6 +24,7 @@ export interface StoreRecord {
24
24
  export async function createStore(
25
25
  client: SupabaseClient,
26
26
  input: CreateStoreInput,
27
+ orgId?: string,
27
28
  ): Promise<{ data?: { id: string }; error?: { message: string } }> {
28
29
  // Validate input against schema
29
30
  const parsed = storeCreateSchema.safeParse(input);
@@ -33,7 +34,7 @@ export async function createStore(
33
34
  };
34
35
  }
35
36
 
36
- const organizationId = await getOrganizationId(client);
37
+ const organizationId = await getOrganizationId(client, orgId);
37
38
 
38
39
  const { data, error } = await client
39
40
  .from("stores")
@@ -55,10 +56,56 @@ export async function createStore(
55
56
  return { data: { id: data.id } };
56
57
  }
57
58
 
59
+ export interface StoreWithOrg {
60
+ id: string;
61
+ name: string;
62
+ organizationId: string;
63
+ organizationName: string | null;
64
+ }
65
+
66
+ // Resolves a store + org by id; RLS returns no row when the user lacks access.
67
+ export async function getStoreById(
68
+ client: SupabaseClient,
69
+ storeId: string,
70
+ ): Promise<{ data?: StoreWithOrg; error?: { message: string } }> {
71
+ const { data, error } = await client
72
+ .from("stores")
73
+ .select("id, name, organization_id, organizations(id, name)")
74
+ .eq("id", storeId)
75
+ .maybeSingle();
76
+
77
+ if (error) {
78
+ return { error: { message: error.message } };
79
+ }
80
+
81
+ if (!data) {
82
+ return {
83
+ error: {
84
+ message: `Store ${storeId} not found or you don't have access to it.`,
85
+ },
86
+ };
87
+ }
88
+
89
+ const org = unwrapToOne<{ name?: string }>(
90
+ (data as { organizations?: unknown }).organizations,
91
+ );
92
+ const orgName = org?.name ?? null;
93
+
94
+ return {
95
+ data: {
96
+ id: data.id as string,
97
+ name: data.name as string,
98
+ organizationId: data.organization_id as string,
99
+ organizationName: orgName,
100
+ },
101
+ };
102
+ }
103
+
58
104
  export async function listStores(
59
105
  client: SupabaseClient,
106
+ orgId?: string,
60
107
  ): Promise<{ data?: StoreRecord[]; error?: { message: string } }> {
61
- const organizationId = await getOrganizationId(client);
108
+ const organizationId = await getOrganizationId(client, orgId);
62
109
 
63
110
  const { data, error } = await client
64
111
  .from("stores")
package/src/utils/auth.ts CHANGED
@@ -231,6 +231,10 @@ export async function getCurrentUser(): Promise<{ email: string } | null> {
231
231
 
232
232
  try {
233
233
  const decoded = jwtDecode<JwtPayload>(credentials.accessToken);
234
+ // Reject expired sessions (local `exp` check; misses server-side revocation).
235
+ if (decoded.exp && decoded.exp * 1000 <= Date.now()) {
236
+ return null;
237
+ }
234
238
  return decoded.email ? { email: decoded.email } : null;
235
239
  } catch {
236
240
  return null;