@exposify/next 0.0.4 → 0.0.6

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/README.md CHANGED
@@ -184,7 +184,11 @@ route reads `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
184
184
  name them, or `supabase: false` to turn it off. The dashboard shows what was
185
185
  found and has the switch for passwords. `inspect` says which way it went and
186
186
  why; `npx @exposify/next test-sign-in` signs in on your running app and checks
187
- a protected page accepts the cookie. `@supabase/auth-helpers-nextjs` (an
187
+ a protected page accepts the cookie. If your `proxy.ts` sends signed-out
188
+ visitors to the login page (Supabase's starter does), it would send AI
189
+ assistants there too, before they can sign in: `inspect` warns, and
190
+ `setup --apply` leaves `/mcp` out of its matcher. Your pages still go through
191
+ it on tool calls. `@supabase/auth-helpers-nextjs` (an
188
192
  older cookie format) isn't supported, and apps on `@supabase/supabase-js`
189
193
  alone keep their session in the browser, where the server never sees it.
190
194
 
@@ -2296,8 +2296,8 @@ function generate(root, opts = {}, warn = (m) => console.warn(`[exposify] ${m}`)
2296
2296
  import { createInterface } from "readline";
2297
2297
 
2298
2298
  // bin/adapter.ts
2299
- import { existsSync as existsSync4, readFileSync as readFileSync9 } from "fs";
2300
- import { dirname as dirname4, join as join9, relative as relative3, resolve as resolve2 } from "path";
2299
+ import { existsSync as existsSync5, readFileSync as readFileSync10 } from "fs";
2300
+ import { dirname as dirname4, join as join10, relative as relative3, resolve as resolve2 } from "path";
2301
2301
  import { decideExposure, deriveDescription, resolveOptions, toolNames } from "@exposify/core";
2302
2302
  import { renderConfig, replaceAiReadyOptions } from "@exposify/cli";
2303
2303
 
@@ -2646,13 +2646,134 @@ function scanActions(d, call, root, notes, suggestions = []) {
2646
2646
  return rows;
2647
2647
  }
2648
2648
 
2649
+ // bin/proxy.ts
2650
+ import { existsSync as existsSync4, readFileSync as readFileSync9 } from "fs";
2651
+ import { join as join9 } from "path";
2652
+ var FILES = ["", "src/"].flatMap((dir) => ["proxy", "middleware"].flatMap((name) => [".ts", ".js", ".mjs"].map((ext) => `${dir}${name}${ext}`)));
2653
+ var REDIRECT = /\bredirect\s*\(/;
2654
+ function matcherRegExp(pattern) {
2655
+ let re = "";
2656
+ for (let i = 0; i < pattern.length; ) {
2657
+ const c = pattern[i];
2658
+ if (c === "(") {
2659
+ let depth = 0;
2660
+ let j = i;
2661
+ for (; j < pattern.length; j++) {
2662
+ if (pattern[j] === "\\") j++;
2663
+ else if (pattern[j] === "(") depth++;
2664
+ else if (pattern[j] === ")" && --depth === 0) break;
2665
+ }
2666
+ re += pattern.slice(i, j + 1);
2667
+ i = j + 1;
2668
+ } else if (c === "/" && pattern[i + 1] === ":") {
2669
+ const m = /^\/:\w+([*+?]?)/.exec(pattern.slice(i));
2670
+ re += m[1] === "*" ? "(?:/.*)?" : m[1] === "+" ? "/.+" : m[1] === "?" ? "(?:/[^/]+)?" : "/[^/]+";
2671
+ i += m[0].length;
2672
+ } else {
2673
+ re += c.replace(/[.*+?^${}|[\]\\]/g, "\\$&");
2674
+ i++;
2675
+ }
2676
+ }
2677
+ try {
2678
+ return new RegExp(`^${re}/?$`);
2679
+ } catch {
2680
+ return void 0;
2681
+ }
2682
+ }
2683
+ function matcherNode(program) {
2684
+ for (const n of program.body) {
2685
+ const decl = n.type === "ExportNamedDeclaration" ? n.declaration : void 0;
2686
+ if (decl?.type !== "VariableDeclaration") continue;
2687
+ for (const d of decl.declarations) {
2688
+ if (d.id?.name !== "config") continue;
2689
+ const obj = unwrapTs(d.init);
2690
+ if (obj?.type !== "ObjectExpression") return "opaque";
2691
+ const prop2 = obj.properties.find((p) => p.type === "ObjectProperty" && (p.key?.name ?? p.key?.value) === "matcher");
2692
+ return prop2 ? unwrapTs(prop2.value) ?? "opaque" : null;
2693
+ }
2694
+ }
2695
+ return null;
2696
+ }
2697
+ function matcherEntries(node) {
2698
+ const items = node.type === "ArrayExpression" ? node.elements : [node];
2699
+ const out = [];
2700
+ for (const item of items) {
2701
+ const literal = item?.type === "ObjectExpression" ? unwrapTs(item.properties.find((p) => (p.key?.name ?? p.key?.value) === "source")?.value) : item;
2702
+ const pattern = stringValue(literal);
2703
+ if (pattern === void 0 || !literal) return void 0;
2704
+ out.push({ pattern, node: literal });
2705
+ }
2706
+ return out;
2707
+ }
2708
+ function redirects(root, file, source, program) {
2709
+ if (REDIRECT.test(source)) return true;
2710
+ const load = moduleLoader(root);
2711
+ for (const n of program.body) {
2712
+ if (n.type !== "ImportDeclaration") continue;
2713
+ const found = load(n.source.value, file);
2714
+ if (found && REDIRECT.test(found.source)) return true;
2715
+ }
2716
+ return false;
2717
+ }
2718
+ function middlewareFile(root) {
2719
+ return FILES.find((f) => existsSync4(join9(root, f)));
2720
+ }
2721
+ function findProxyGate(root, mcpPath = "/mcp") {
2722
+ const file = middlewareFile(root);
2723
+ if (!file) return void 0;
2724
+ const source = readFileSync9(join9(root, file), "utf8");
2725
+ const program = parseModule(source, file);
2726
+ if (!program || !redirects(root, file, source, program)) return void 0;
2727
+ const matcher = matcherNode(program);
2728
+ if (matcher === "opaque") return void 0;
2729
+ if (matcher === null) return { file, mcpPath, matching: [] };
2730
+ const entries = matcherEntries(matcher);
2731
+ if (!entries) return void 0;
2732
+ const probes = [mcpPath, `${mcpPath.replace(/\/$/, "")}/login`];
2733
+ const matching = entries.filter((e) => probes.some((p) => matcherRegExp(e.pattern)?.test(p))).map((e) => e.pattern);
2734
+ return matching.length > 0 ? { file, mcpPath, matching } : void 0;
2735
+ }
2736
+ function proxyGateHint(gate) {
2737
+ return `${gate.file} sends signed-out requests to another page and runs on ${gate.mcpPath}, so AI assistants get that page instead of a sign-in. Leave ${gate.mcpPath} out of its matcher (the MCP route checks its own callers): ` + (gate.matching.some((p) => p.startsWith("/((?!")) ? `add ${exclusion(gate.mcpPath)}| after "(?!" in config.matcher.` : `matcher: ["/((?!${exclusion(gate.mcpPath)}).*)"].`);
2738
+ }
2739
+ var exclusion = (mcpPath) => `${mcpPath.replace(/^\/+|\/+$/g, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:/|$)`;
2740
+ var inSource = (mcpPath) => exclusion(mcpPath).replace(/\\/g, "\\\\");
2741
+ function excludeMcpFromProxy(root, gate) {
2742
+ const file = join9(root, gate.file);
2743
+ const before = readFileSync9(file, "utf8");
2744
+ const program = parseModule(before, gate.file);
2745
+ if (!program) return { blocked: `couldn't parse ${gate.file}` };
2746
+ const summary = `Leave ${gate.mcpPath} out of ${gate.file}'s matcher, so AI assistants reach the sign-in instead of your login page`;
2747
+ const matcher = matcherNode(program);
2748
+ if (matcher === null) {
2749
+ const after2 = `${before.replace(/\n*$/, "\n")}
2750
+ export const config = {
2751
+ matcher: ["/((?!${inSource(gate.mcpPath)}).*)"],
2752
+ };
2753
+ `;
2754
+ return { plan: { file, summary, before, after: after2 } };
2755
+ }
2756
+ const entries = matcher === "opaque" ? void 0 : matcherEntries(matcher);
2757
+ if (!entries) return { blocked: `${gate.file}'s matcher isn't a list of strings` };
2758
+ let after = before;
2759
+ for (const e of [...entries].reverse()) {
2760
+ if (!gate.matching.includes(e.pattern)) continue;
2761
+ const raw = before.slice(e.node.start, e.node.end);
2762
+ const at = raw.indexOf("/((?!");
2763
+ if (at < 0) return { blocked: `${gate.file}'s matcher "${e.pattern}" has no "(?!" to add ${gate.mcpPath} to` };
2764
+ const insert = e.node.start + at + "/((?!".length;
2765
+ after = `${after.slice(0, insert)}${inSource(gate.mcpPath)}|${after.slice(insert)}`;
2766
+ }
2767
+ return { plan: { file, summary, before, after } };
2768
+ }
2769
+
2649
2770
  // bin/adapter.ts
2650
2771
  var MCP_ROUTE_DIR = "mcp/[[...transport]]";
2651
2772
  function isNextProject(dir) {
2652
2773
  try {
2653
- const pkg = JSON.parse(readFileSync9(join9(dir, "package.json"), "utf8"));
2774
+ const pkg = JSON.parse(readFileSync10(join10(dir, "package.json"), "utf8"));
2654
2775
  const hasNext = Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
2655
- return hasNext && ["app", "src/app", "pages", "src/pages"].some((d) => existsSync4(join9(dir, d)));
2776
+ return hasNext && ["app", "src/app", "pages", "src/pages"].some((d) => existsSync5(join10(dir, d)));
2656
2777
  } catch {
2657
2778
  return false;
2658
2779
  }
@@ -2667,6 +2788,10 @@ var nextAdapter = {
2667
2788
  bin: "npx @exposify/next",
2668
2789
  entryExample: "apps/web",
2669
2790
  entryHelp: "The Next.js project directory",
2791
+ commands: [
2792
+ ["generate", "Write exposify.generated.ts (withExposify() in next.config does it on dev and build)"],
2793
+ ["test-sign-in", "Check, on your running app, that the Supabase session Connect sends signs you in"]
2794
+ ],
2670
2795
  locate(cwd, entryFlag) {
2671
2796
  const root = findNextRoot(entryFlag ? resolve2(cwd, entryFlag) : cwd);
2672
2797
  return root ? { entry: root, root, via: "package.json" } : void 0;
@@ -2697,26 +2822,25 @@ ${block}
2697
2822
  if (stubs.some((s) => s.includes("TODO(exposify)"))) {
2698
2823
  lines.push("Go through the TODO(exposify) lines: an action whose input is still incomplete stays hidden until you write it out.");
2699
2824
  }
2700
- if (!hasWithExposify(root)) {
2701
- lines.push(
2702
- 'Wrap your next.config export: `export default withExposify(nextConfig)`, imported from "@exposify/next/config". It keeps exposify.generated.ts current on `next dev` and `next build`.'
2703
- );
2704
- }
2705
- const existing = ["exposify.generated.ts", "exposify.generated.js"].map((f) => join9(root, f)).find((f) => existsSync4(f));
2825
+ const wrap = hasWithExposify(root) ? null : wrapNextConfig(root);
2826
+ const also = wrap && "plan" in wrap ? [{ plan: wrap.plan, manual: WRAP_HINT }] : [];
2827
+ if (wrap && !("plan" in wrap)) lines.push(WRAP_HINT);
2828
+ const gate = findProxyGate(root, d.mcpPath ?? "/mcp");
2829
+ const unblock = gate ? excludeMcpFromProxy(root, gate) : null;
2830
+ if (gate && unblock && "plan" in unblock) also.push({ plan: unblock.plan, manual: proxyGateHint(gate) });
2831
+ else if (gate) lines.push(proxyGateHint(gate));
2832
+ const existing = ["exposify.generated.ts", "exposify.generated.js"].map((f) => join10(root, f)).find((f) => existsSync5(f));
2706
2833
  const manifestFile = existing ?? generate(root, {}, () => {
2707
2834
  }).file;
2708
2835
  const generated = relative3(root, manifestFile);
2709
2836
  if (result.aiReady?.site) {
2710
2837
  const file = relative3(root, result.aiReady.site.file);
2711
2838
  if (usesZod) lines.push('The input stubs use zod: `import { z } from "zod";` in that file, if it isn\'t there.');
2712
- return {
2713
- code: options,
2714
- hint: { lines: [`Replace the aiReady(...) call in ${file}:${result.aiReady.site.line} with this.`, ...lines] },
2715
- apply: applyReplace(result.aiReady.site, config, entries, usesZod, file)
2716
- };
2839
+ const apply = withAlso(applyReplace(result.aiReady.site, config, entries, usesZod, file), also, lines);
2840
+ return { code: options, hint: { lines: [`Replace the aiReady(...) call in ${file}:${result.aiReady.site.line} with this.`, ...lines] }, apply };
2717
2841
  }
2718
2842
  const routeFile = `${d.appDir ?? "app"}/${MCP_ROUTE_DIR}/route.${generated.endsWith(".ts") ? "ts" : "js"}`;
2719
- const manifestImport = relative3(join9(root, dirname4(routeFile)), manifestFile).replace(/\.[jt]s$/, "");
2843
+ const manifestImport = relative3(join10(root, dirname4(routeFile)), manifestFile).replace(/\.[jt]s$/, "");
2720
2844
  const code = [
2721
2845
  'import { aiReady } from "@exposify/next";',
2722
2846
  ...usesZod ? ['import { z } from "zod";'] : [],
@@ -2728,16 +2852,50 @@ ${block}
2728
2852
  ].join("\n");
2729
2853
  return {
2730
2854
  code,
2731
- apply: { plan: { file: join9(root, routeFile), summary: `Create ${routeFile}`, before: null, after: `${code}
2732
- ` } },
2855
+ apply: { plan: { file: join10(root, routeFile), summary: `Create ${routeFile}`, before: null, after: `${code}
2856
+ ` }, ...also.length ? { also } : {} },
2733
2857
  hint: { lines: [`Create ${routeFile} with this (agents connect to /mcp). It imports ${generated}${existing ? "" : ", written just now"}.`, ...lines] }
2734
2858
  };
2735
2859
  }
2736
2860
  };
2861
+ var WRAP_HINT = 'Wrap your next.config export: `export default withExposify(nextConfig)`, imported from "@exposify/next/config". It keeps exposify.generated.ts current on `next dev` and `next build`.';
2862
+ var NEXT_CONFIGS = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs", "next.config.mts"];
2863
+ function withAlso(outcome, also, lines) {
2864
+ if ("plan" in outcome) return also.length ? { ...outcome, also } : outcome;
2865
+ lines.push(...also.map((a) => a.manual));
2866
+ return outcome;
2867
+ }
2868
+ function wrapNextConfig(root) {
2869
+ const name = NEXT_CONFIGS.find((n) => existsSync5(join10(root, n)));
2870
+ if (!name) return { blocked: "no next.config file" };
2871
+ const file = join10(root, name);
2872
+ const before = readFileSync10(file, "utf8");
2873
+ const program = parseModule(before, name);
2874
+ if (!program) return { blocked: `couldn't parse ${name}` };
2875
+ const body = program.body;
2876
+ const esm = body.find((n) => n.type === "ExportDefaultDeclaration");
2877
+ const cjs = body.find((n) => {
2878
+ const e = n.type === "ExpressionStatement" ? unwrapTs(n.expression) : void 0;
2879
+ return e?.type === "AssignmentExpression" && e.left?.type === "MemberExpression" && e.left.object?.name === "module" && e.left.property?.name === "exports";
2880
+ });
2881
+ const value = esm ? esm.declaration : cjs ? unwrapTs(cjs.expression)?.right : void 0;
2882
+ if (!value || value.start == null || value.end == null || /Declaration$/.test(value.type)) return { blocked: `couldn't find the config ${name} exports` };
2883
+ const wrapped = `${before.slice(0, value.start)}withExposify(${before.slice(value.start, value.end)})${before.slice(value.end)}`;
2884
+ const imports = body.filter((n) => n.type === "ImportDeclaration" || n.type === "VariableDeclaration" && /require\(/.test(before.slice(n.start, n.end)));
2885
+ const at = imports.length ? imports.at(-1).end : 0;
2886
+ const last = at ? before.slice(imports.at(-1).start, at) : "";
2887
+ const q = /'/.test(last) ? "'" : '"';
2888
+ const semi = !last || last.trimEnd().endsWith(";") ? ";" : "";
2889
+ const line = esm ? `import { withExposify } from ${q}@exposify/next/config${q}${semi}` : `const { withExposify } = require(${q}@exposify/next/config${q})${semi}`;
2890
+ const after = at ? `${wrapped.slice(0, at)}
2891
+ ${line}${wrapped.slice(at)}` : `${line}
2892
+ ${wrapped}`;
2893
+ return { plan: { file, summary: `Wrap ${name}'s export in withExposify()`, before, after } };
2894
+ }
2737
2895
  function hasWithExposify(root) {
2738
- for (const name of ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs", "next.config.mts"]) {
2896
+ for (const name of NEXT_CONFIGS) {
2739
2897
  try {
2740
- if (readFileSync9(join9(root, name), "utf8").includes("withExposify")) return true;
2898
+ if (readFileSync10(join10(root, name), "utf8").includes("withExposify")) return true;
2741
2899
  } catch {
2742
2900
  }
2743
2901
  }
@@ -2752,13 +2910,21 @@ function scanProject(root) {
2752
2910
  const notes = [];
2753
2911
  const descriptions = readDescriptions(root, (m) => notes.push(m));
2754
2912
  const rows = [];
2913
+ const callbacks = new Set(d.supabase?.choice.callbacks ?? []);
2755
2914
  for (const r of d.routes) {
2756
2915
  if (r.router === "app") {
2916
+ const callback = callbacks.has(r.file);
2757
2917
  for (const method of r.module.methods.filter((m) => m !== "OPTIONS")) {
2758
2918
  const m = r.module.markers[method];
2759
2919
  const marker = m ? m.kind === "described" ? { kind: "described", ...m.name ? { name: m.name } : {}, ...m.description ? { description: m.description } : {} } : { kind: m.kind } : void 0;
2760
2920
  const source = r.module.sources[method];
2761
- rows.push({ info: { method, path: r.path, params: r.params, ...marker ? { marker } : {} }, ...m?.name ? { describedName: m.name } : {}, ...m?.description ? { describedText: m.description } : {}, ...source ? { source } : {} });
2921
+ rows.push({
2922
+ info: { method, path: r.path, params: r.params, ...marker ? { marker } : {}, ...callback ? { optIn: true } : {} },
2923
+ ...m?.name ? { describedName: m.name } : {},
2924
+ ...m?.description ? { describedText: m.description } : {},
2925
+ ...source ? { source } : {},
2926
+ ...callback ? { callback: true } : {}
2927
+ });
2762
2928
  }
2763
2929
  continue;
2764
2930
  }
@@ -2776,7 +2942,7 @@ function scanProject(root) {
2776
2942
  for (const p of d.pages) {
2777
2943
  let source;
2778
2944
  try {
2779
- source = readFileSync9(join9(root, p.file), "utf8");
2945
+ source = readFileSync10(join10(root, p.file), "utf8");
2780
2946
  } catch {
2781
2947
  source = void 0;
2782
2948
  }
@@ -2803,6 +2969,8 @@ function scanProject(root) {
2803
2969
  for (const row of rows) if (row.page && row.describedName) names.set(`GET ${row.info.path}`, row.describedName);
2804
2970
  const suggestions = [];
2805
2971
  const actions = scanActions(d, call, root, notes, suggestions);
2972
+ const gate = findProxyGate(root, d.mcpPath ?? "/mcp");
2973
+ if (gate) notes.push(`${proxyGateHint(gate)} setup --apply can make this change.`);
2806
2974
  const routes = rows.map((row) => {
2807
2975
  const key = `${row.info.method} ${row.info.path}`;
2808
2976
  const decision = exposure.get(key);
@@ -2817,8 +2985,9 @@ function scanProject(root) {
2817
2985
  tool: names.get(key),
2818
2986
  ...derived ? { derived } : {},
2819
2987
  exposed: decision.exposed,
2820
- reason: row.formOnly && !decision.exposed ? `a page that's just a form posting to a Server Action (the action is the tool); list "GET ${row.info.path}" in expose to read it anyway` : row.page && decision.exposed ? "a page: read like a browser reads it" : decision.reason,
2988
+ reason: row.formOnly && !decision.exposed ? `a page that's just a form posting to a Server Action (the action is the tool); list "GET ${row.info.path}" in expose to read it anyway` : row.callback && !decision.exposed ? `finishes a Supabase sign-in from an email link or a redirect, which an agent has no use for; list "${key}" in expose to make it a tool anyway` : row.page && decision.exposed ? "a page: read like a browser reads it" : decision.reason,
2821
2989
  ...row.info.optIn ? { optIn: true } : {},
2990
+ ...row.formOnly || row.callback ? { offByDefault: true } : {},
2822
2991
  ...row.page ? { kind: "page" } : {},
2823
2992
  ...row.source ? { source: row.source } : {}
2824
2993
  };
@@ -2844,7 +3013,7 @@ function scanProject(root) {
2844
3013
  } : {},
2845
3014
  ...call && d.mcpRoute ? {
2846
3015
  aiReady: {
2847
- site: { file: join9(root, d.mcpRoute.file), line: call.line },
3016
+ site: { file: join10(root, d.mcpRoute.file), line: call.line },
2848
3017
  options: {
2849
3018
  ...call.expose ? { expose: call.expose } : {},
2850
3019
  ...call.exclude ? { exclude: call.exclude } : {},
@@ -2858,7 +3027,7 @@ function scanProject(root) {
2858
3027
  }
2859
3028
  function applyReplace(site, config, entries, usesZod, relFile) {
2860
3029
  try {
2861
- const before = readFileSync9(site.file, "utf8");
3030
+ const before = readFileSync10(site.file, "utf8");
2862
3031
  const edit = replaceAiReadyOptions({
2863
3032
  source: before,
2864
3033
  file: site.file,
@@ -2877,8 +3046,8 @@ function applyReplace(site, config, entries, usesZod, relFile) {
2877
3046
  }
2878
3047
 
2879
3048
  // bin/test-sign-in.ts
2880
- import { readFileSync as readFileSync10 } from "fs";
2881
- import { join as join10 } from "path";
3049
+ import { readFileSync as readFileSync11 } from "fs";
3050
+ import { join as join11 } from "path";
2882
3051
  import { parseEnv } from "util";
2883
3052
  import { supabaseCookieName, supabasePasswordLogin, supabaseSessionCookies } from "@exposify/core";
2884
3053
  var TEST_SIGN_IN_USAGE = `Usage: @exposify/next test-sign-in [options]
@@ -2886,10 +3055,13 @@ var TEST_SIGN_IN_USAGE = `Usage: @exposify/next test-sign-in [options]
2886
3055
  Signs in to your app's Supabase Auth as AI assistants will (a password, then
2887
3056
  the authenticator code if you have one), and checks that a page your app
2888
3057
  protects accepts the session cookie Connect sends. Run it with your app up.
3058
+ With no page that turns signed-out visitors away, it looks for one that
3059
+ shows your email once signed in.
2889
3060
 
2890
3061
  Options:
2891
3062
  --url <origin> Where the app runs (default: http://localhost:3000)
2892
- --page <path> A page that redirects signed-out visitors (default: found)
3063
+ --page <path> A page that redirects signed-out visitors, or shows
3064
+ who's signed in (default: found)
2893
3065
  --entry <path> The Next.js project directory (default: detected)
2894
3066
 
2895
3067
  Reads NEXT_PUBLIC_SUPABASE_URL and the publishable (or anon) key from the
@@ -2897,7 +3069,7 @@ environment, .env.local or .env. Signs the test session out afterwards.`;
2897
3069
  function supabaseEnv(root, env) {
2898
3070
  const files = [".env.local", ".env"].map((f) => {
2899
3071
  try {
2900
- return parseEnv(readFileSync10(join10(root, f), "utf8"));
3072
+ return parseEnv(readFileSync11(join11(root, f), "utf8"));
2901
3073
  } catch {
2902
3074
  return {};
2903
3075
  }
@@ -2920,36 +3092,27 @@ async function testSignIn(root, opts, deps) {
2920
3092
  return 1;
2921
3093
  }
2922
3094
  const origin = opts.url.replace(/\/+$/, "");
2923
- const get = (path, cookie) => deps.fetch(`${origin}${path}`, { headers: cookie ? { cookie } : {}, redirect: "manual" }).catch(() => null);
2924
- let page = opts.page;
2925
- if (!page) {
2926
- const skip = new Set(d.supabase.choice.loginPages ?? []);
2927
- const candidates2 = d.pages.filter((p) => p.params.length === 0 && !skip.has(p.path) && !p.path.startsWith(d.mcpPath ?? "/mcp")).slice(0, 15);
2928
- for (const p of candidates2) {
2929
- const res2 = await get(p.path);
2930
- if (res2 === null) {
2931
- deps.log(`Couldn't reach ${origin}. Start your app (next dev), or pass --url.`);
2932
- return 1;
2933
- }
2934
- if (redirected(res2)) {
2935
- page = p.path;
2936
- break;
2937
- }
2938
- }
2939
- if (!page) {
2940
- deps.log("No page turned a signed-out request away, so there's nothing to check the session against. Pass --page with one that does.");
2941
- return 1;
2942
- }
2943
- } else {
2944
- const res2 = await get(page);
2945
- if (res2 === null) {
3095
+ const get = (path, cookie2) => deps.fetch(`${origin}${path}`, { headers: cookie2 ? { cookie: cookie2 } : {}, redirect: "manual" }).catch(() => null);
3096
+ let page;
3097
+ const open = [];
3098
+ const candidates2 = opts.page ? [opts.page] : d.pages.filter((p) => p.params.length === 0 && !(d.supabase.choice.loginPages ?? []).includes(p.path) && !p.path.startsWith(d.mcpPath ?? "/mcp")).slice(0, 15).map((p) => p.path);
3099
+ for (const path of candidates2) {
3100
+ const res = await get(path);
3101
+ if (res === null) {
2946
3102
  deps.log(`Couldn't reach ${origin}. Start your app (next dev), or pass --url.`);
2947
3103
  return 1;
2948
3104
  }
2949
- if (!redirected(res2)) {
2950
- deps.log(`${page} answered ${res2.status} without signing in, so it can't tell whether the session works. Pass --page with a page that redirects signed-out visitors.`);
2951
- return 1;
3105
+ if (redirected(res)) {
3106
+ page = path;
3107
+ break;
2952
3108
  }
3109
+ if (res.ok) open.push({ path, body: await res.text() });
3110
+ }
3111
+ if (!page && open.length === 0) {
3112
+ deps.log(
3113
+ opts.page ? `${opts.page} answered with an error without signing in, so it can't tell whether the session works. Pass --page with a page that redirects signed-out visitors or shows who's signed in.` : "No page answered a signed-out request, so there's nothing to check the session against. Pass --page with one that redirects signed-out visitors or shows who's signed in."
3114
+ );
3115
+ return 1;
2953
3116
  }
2954
3117
  const login = supabasePasswordLogin({ ...project, fetch: deps.fetch });
2955
3118
  const ctx = { origin };
@@ -2967,13 +3130,31 @@ async function testSignIn(root, opts, deps) {
2967
3130
  const session = result.session.supabase;
2968
3131
  const name = d.supabase.choice.cookieName ?? supabaseCookieName(project.url);
2969
3132
  const cookies = supabaseSessionCookies(name, session);
2970
- const res = await get(page, cookies.map((c) => `${c.name}=${c.value}`).join("; "));
2971
- await deps.fetch(`${project.url.replace(/\/+$/, "")}/auth/v1/logout?scope=local`, { method: "POST", headers: { apikey: project.key, authorization: `Bearer ${session.accessToken}` } }).catch(() => null);
2972
- if (res && res.status >= 200 && res.status < 300) {
2973
- deps.log(`\u2713 ${page} answered ${res.status} with the session cookie Connect sends (${cookies.length === 1 ? name : `${name}.0\u2013.${cookies.length - 1}`}), and turned you away without it.`);
2974
- return 0;
3133
+ const cookie = cookies.map((c) => `${c.name}=${c.value}`).join("; ");
3134
+ const cookieLabel = cookies.length === 1 ? name : `${name}.0\u2013.${cookies.length - 1}`;
3135
+ let ok = false;
3136
+ if (page) {
3137
+ const res = await get(page, cookie);
3138
+ ok = Boolean(res && res.status >= 200 && res.status < 300);
3139
+ if (ok) deps.log(`\u2713 ${page} answered ${res.status} with the session cookie Connect sends (${cookieLabel}), and turned you away without it.`);
3140
+ else deps.log(`\u2717 ${page} answered ${res?.status ?? "nothing"} with the session cookie${res?.headers.get("location") ? ` (to ${res.headers.get("location")})` : ""}: your app didn't accept it.`);
3141
+ } else {
3142
+ const shown = (typeof session.user?.email === "string" ? session.user.email : email).toLowerCase();
3143
+ for (const p of open.filter((o) => !o.body.toLowerCase().includes(shown))) {
3144
+ const res = await get(p.path, cookie);
3145
+ if (res?.ok && (await res.text()).toLowerCase().includes(shown)) {
3146
+ deps.log(`\u2713 ${p.path} showed ${shown} with the session cookie Connect sends (${cookieLabel}), and didn't without it.`);
3147
+ ok = true;
3148
+ break;
3149
+ }
3150
+ }
3151
+ if (!ok) {
3152
+ deps.log(`\u2717 No page turns signed-out visitors away, and none of ${open.map((o) => o.path).join(", ")} showed ${shown} with the session cookie: your app may not have accepted it.`);
3153
+ deps.log(" Pass --page with a page that redirects signed-out visitors or shows who's signed in, to check against it.");
3154
+ }
2975
3155
  }
2976
- deps.log(`\u2717 ${page} answered ${res?.status ?? "nothing"} with the session cookie${res?.headers.get("location") ? ` (to ${res.headers.get("location")})` : ""}: your app didn't accept it.`);
3156
+ await deps.fetch(`${project.url.replace(/\/+$/, "")}/auth/v1/logout?scope=local`, { method: "POST", headers: { apikey: project.key, authorization: `Bearer ${session.accessToken}` } }).catch(() => null);
3157
+ if (ok) return 0;
2977
3158
  if (d.supabase.choice.cookieName === void 0) deps.log(` It was named ${name}. If your app sets another cookie name, the generator didn't see it.`);
2978
3159
  return 1;
2979
3160
  }
package/dist/index.cjs CHANGED
@@ -444,9 +444,58 @@ function tokenize(html) {
444
444
  if (last < html.length) out.push(html.slice(last));
445
445
  return out;
446
446
  }
447
+ function divEnd(html, from) {
448
+ const re = /<div\b(?:[^>"']|"[^"]*"|'[^']*')*>|<\/div\s*>/gi;
449
+ re.lastIndex = from;
450
+ let depth = 1;
451
+ for (let m = re.exec(html); m; m = re.exec(html)) {
452
+ depth += m[0][1] === "/" ? -1 : 1;
453
+ if (depth === 0) return re.lastIndex;
454
+ }
455
+ return html.length;
456
+ }
457
+ function boundaryEnd(html, from) {
458
+ const re = /<!--\$[?!]?-->|<!--\/\$-->/g;
459
+ re.lastIndex = from;
460
+ let depth = 1;
461
+ for (let m = re.exec(html); m; m = re.exec(html)) {
462
+ depth += m[0][4] === "/" ? -1 : 1;
463
+ if (depth === 0) return re.lastIndex;
464
+ }
465
+ return html.length;
466
+ }
467
+ function resolveStreamed(html) {
468
+ const chunks = [];
469
+ const open = /<div\s+hidden(?:="")?\s+id="S:([0-9a-f]+)"\s*>/g;
470
+ let out = "";
471
+ let last = 0;
472
+ for (let m = open.exec(html); m; m = open.exec(html)) {
473
+ const end = divEnd(html, open.lastIndex);
474
+ chunks.push({ id: m[1], content: html.slice(open.lastIndex, end).replace(/<\/div\s*>$/i, "") });
475
+ out += html.slice(last, m.index);
476
+ last = end;
477
+ open.lastIndex = end;
478
+ }
479
+ if (chunks.length === 0) return html;
480
+ out += html.slice(last);
481
+ for (let moved = true; moved; ) {
482
+ moved = false;
483
+ for (const c of chunks) {
484
+ const marker = `<template id="B:${c.id}"></template>`;
485
+ const at = out.indexOf(marker);
486
+ if (at < 0) continue;
487
+ const inBoundary = out.slice(Math.max(0, at - 9), at) === "<!--$?-->";
488
+ const start = inBoundary ? at - 9 : at;
489
+ const end = inBoundary ? boundaryEnd(out, at) : at + marker.length;
490
+ out = out.slice(0, start) + c.content + out.slice(end);
491
+ moved = true;
492
+ }
493
+ }
494
+ return out;
495
+ }
447
496
  var hidden = (t) => "hidden" in t.attrs || t.attrs["aria-hidden"] === "true" || /display\s*:\s*none|visibility\s*:\s*hidden/i.test(t.attrs.style ?? "") || t.name === "input" && t.attrs.type === "hidden";
448
497
  function htmlToText(html) {
449
- const tokens = tokenize(html);
498
+ const tokens = tokenize(resolveStreamed(html));
450
499
  const hasMain = tokens.some((t) => typeof t !== "string" && t.name === "main" && !t.close);
451
500
  const lines = [];
452
501
  let line = "";
@@ -698,6 +747,7 @@ function errorMessage(err) {
698
747
  }
699
748
  async function routesFrom(manifest, cfg, warnings) {
700
749
  const listed = Array.isArray(cfg.expose) ? cfg.expose.map(import_core.parseMethodEntry).filter((e) => e !== null) : [];
750
+ const callbacks = new Set(manifest.supabase?.callbacks ?? []);
701
751
  const perEntry = await Promise.all(
702
752
  manifest.routes.map(async (entry) => {
703
753
  const base = { path: entry.path, params: entry.params };
@@ -715,7 +765,7 @@ async function routesFrom(manifest, cfg, warnings) {
715
765
  const methods = mod ? HTTP_METHODS.filter((m) => typeof mod[m] === "function") : entry.methods;
716
766
  return methods.filter((m) => m !== "OPTIONS").map((method) => {
717
767
  const marker = mod ? (0, import_core.getMarker)(mod[method]) : void 0;
718
- return { ...base, method, ...marker ? { marker } : {} };
768
+ return { ...base, method, ...marker ? { marker } : {}, ...callbacks.has(entry.file) ? { optIn: true } : {} };
719
769
  });
720
770
  })
721
771
  );
package/dist/index.js CHANGED
@@ -454,9 +454,58 @@ function tokenize(html) {
454
454
  if (last < html.length) out.push(html.slice(last));
455
455
  return out;
456
456
  }
457
+ function divEnd(html, from) {
458
+ const re = /<div\b(?:[^>"']|"[^"]*"|'[^']*')*>|<\/div\s*>/gi;
459
+ re.lastIndex = from;
460
+ let depth = 1;
461
+ for (let m = re.exec(html); m; m = re.exec(html)) {
462
+ depth += m[0][1] === "/" ? -1 : 1;
463
+ if (depth === 0) return re.lastIndex;
464
+ }
465
+ return html.length;
466
+ }
467
+ function boundaryEnd(html, from) {
468
+ const re = /<!--\$[?!]?-->|<!--\/\$-->/g;
469
+ re.lastIndex = from;
470
+ let depth = 1;
471
+ for (let m = re.exec(html); m; m = re.exec(html)) {
472
+ depth += m[0][4] === "/" ? -1 : 1;
473
+ if (depth === 0) return re.lastIndex;
474
+ }
475
+ return html.length;
476
+ }
477
+ function resolveStreamed(html) {
478
+ const chunks = [];
479
+ const open = /<div\s+hidden(?:="")?\s+id="S:([0-9a-f]+)"\s*>/g;
480
+ let out = "";
481
+ let last = 0;
482
+ for (let m = open.exec(html); m; m = open.exec(html)) {
483
+ const end = divEnd(html, open.lastIndex);
484
+ chunks.push({ id: m[1], content: html.slice(open.lastIndex, end).replace(/<\/div\s*>$/i, "") });
485
+ out += html.slice(last, m.index);
486
+ last = end;
487
+ open.lastIndex = end;
488
+ }
489
+ if (chunks.length === 0) return html;
490
+ out += html.slice(last);
491
+ for (let moved = true; moved; ) {
492
+ moved = false;
493
+ for (const c of chunks) {
494
+ const marker = `<template id="B:${c.id}"></template>`;
495
+ const at = out.indexOf(marker);
496
+ if (at < 0) continue;
497
+ const inBoundary = out.slice(Math.max(0, at - 9), at) === "<!--$?-->";
498
+ const start = inBoundary ? at - 9 : at;
499
+ const end = inBoundary ? boundaryEnd(out, at) : at + marker.length;
500
+ out = out.slice(0, start) + c.content + out.slice(end);
501
+ moved = true;
502
+ }
503
+ }
504
+ return out;
505
+ }
457
506
  var hidden = (t) => "hidden" in t.attrs || t.attrs["aria-hidden"] === "true" || /display\s*:\s*none|visibility\s*:\s*hidden/i.test(t.attrs.style ?? "") || t.name === "input" && t.attrs.type === "hidden";
458
507
  function htmlToText(html) {
459
- const tokens = tokenize(html);
508
+ const tokens = tokenize(resolveStreamed(html));
460
509
  const hasMain = tokens.some((t) => typeof t !== "string" && t.name === "main" && !t.close);
461
510
  const lines = [];
462
511
  let line = "";
@@ -712,6 +761,7 @@ function errorMessage(err) {
712
761
  }
713
762
  async function routesFrom(manifest, cfg, warnings) {
714
763
  const listed = Array.isArray(cfg.expose) ? cfg.expose.map(parseMethodEntry).filter((e) => e !== null) : [];
764
+ const callbacks = new Set(manifest.supabase?.callbacks ?? []);
715
765
  const perEntry = await Promise.all(
716
766
  manifest.routes.map(async (entry) => {
717
767
  const base = { path: entry.path, params: entry.params };
@@ -729,7 +779,7 @@ async function routesFrom(manifest, cfg, warnings) {
729
779
  const methods = mod ? HTTP_METHODS.filter((m) => typeof mod[m] === "function") : entry.methods;
730
780
  return methods.filter((m) => m !== "OPTIONS").map((method) => {
731
781
  const marker = mod ? getMarker(mod[method]) : void 0;
732
- return { ...base, method, ...marker ? { marker } : {} };
782
+ return { ...base, method, ...marker ? { marker } : {}, ...callbacks.has(entry.file) ? { optIn: true } : {} };
733
783
  });
734
784
  })
735
785
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/next",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@babel/parser": "^8.0.0",
35
- "@exposify/cli": "0.0.3",
35
+ "@exposify/cli": "0.0.5",
36
36
  "@exposify/core": "0.3.16",
37
37
  "tinyglobby": "^0.2.10",
38
38
  "zod": "^3.24.0"