@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 +5 -1
- package/dist/exposify-next.js +242 -61
- package/dist/index.cjs +52 -2
- package/dist/index.js +52 -2
- package/package.json +2 -2
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.
|
|
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
|
|
package/dist/exposify-next.js
CHANGED
|
@@ -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
|
|
2300
|
-
import { dirname as dirname4, join as
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
-
|
|
2713
|
-
|
|
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(
|
|
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:
|
|
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
|
|
2896
|
+
for (const name of NEXT_CONFIGS) {
|
|
2739
2897
|
try {
|
|
2740
|
-
if (
|
|
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({
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
2881
|
-
import { join as
|
|
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
|
|
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(
|
|
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,
|
|
2924
|
-
let page
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
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 (
|
|
2950
|
-
|
|
2951
|
-
|
|
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
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|