@agent-native/core 0.40.2 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +57 -0
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/index.js +16 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +11 -0
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -0
- package/dist/cli/pr-visual-recap-workflow.js +11 -0
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -0
- package/dist/cli/recap.d.ts +52 -0
- package/dist/cli/recap.d.ts.map +1 -0
- package/dist/cli/recap.js +581 -0
- package/dist/cli/recap.js.map +1 -0
- package/dist/cli/skills.d.ts +17 -4
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +60 -16
- package/dist/cli/skills.js.map +1 -1
- package/dist/cli/templates-meta.js +1 -1
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/cli/workspacify.d.ts.map +1 -1
- package/dist/cli/workspacify.js +19 -4
- package/dist/cli/workspacify.js.map +1 -1
- package/dist/client/blocks/index.d.ts +3 -0
- package/dist/client/blocks/index.d.ts.map +1 -1
- package/dist/client/blocks/index.js +3 -0
- package/dist/client/blocks/index.js.map +1 -1
- package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts +6 -0
- package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -0
- package/dist/client/blocks/library/AnnotatedCodeBlock.js +134 -0
- package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -0
- package/dist/client/blocks/library/HighlightedCode.d.ts +21 -1
- package/dist/client/blocks/library/HighlightedCode.d.ts.map +1 -1
- package/dist/client/blocks/library/HighlightedCode.js +86 -4
- package/dist/client/blocks/library/HighlightedCode.js.map +1 -1
- package/dist/client/blocks/library/annotated-code.config.d.ts +58 -0
- package/dist/client/blocks/library/annotated-code.config.d.ts.map +1 -0
- package/dist/client/blocks/library/annotated-code.config.js +53 -0
- package/dist/client/blocks/library/annotated-code.config.js.map +1 -0
- package/dist/client/blocks/library/checklist.js +2 -2
- package/dist/client/blocks/library/checklist.js.map +1 -1
- package/dist/client/blocks/library/code-highlight.d.ts +16 -0
- package/dist/client/blocks/library/code-highlight.d.ts.map +1 -0
- package/dist/client/blocks/library/code-highlight.js +160 -0
- package/dist/client/blocks/library/code-highlight.js.map +1 -0
- package/dist/client/blocks/library/code-tabs.config.d.ts +6 -0
- package/dist/client/blocks/library/code-tabs.config.d.ts.map +1 -1
- package/dist/client/blocks/library/code-tabs.config.js +1 -0
- package/dist/client/blocks/library/code-tabs.config.js.map +1 -1
- package/dist/client/blocks/library/code-tabs.d.ts.map +1 -1
- package/dist/client/blocks/library/code-tabs.js +35 -5
- package/dist/client/blocks/library/code-tabs.js.map +1 -1
- package/dist/client/blocks/library/code.config.d.ts +43 -0
- package/dist/client/blocks/library/code.config.d.ts.map +1 -0
- package/dist/client/blocks/library/code.config.js +34 -0
- package/dist/client/blocks/library/code.config.js.map +1 -0
- package/dist/client/blocks/library/code.d.ts +3 -0
- package/dist/client/blocks/library/code.d.ts.map +1 -0
- package/dist/client/blocks/library/code.js +95 -0
- package/dist/client/blocks/library/code.js.map +1 -0
- package/dist/client/blocks/library/dev-doc-ui.d.ts +2 -1
- package/dist/client/blocks/library/dev-doc-ui.d.ts.map +1 -1
- package/dist/client/blocks/library/dev-doc-ui.js +2 -1
- package/dist/client/blocks/library/dev-doc-ui.js.map +1 -1
- package/dist/client/blocks/library/server-specs.d.ts.map +1 -1
- package/dist/client/blocks/library/server-specs.js +21 -0
- package/dist/client/blocks/library/server-specs.js.map +1 -1
- package/dist/client/blocks/library/specs.d.ts +1 -1
- package/dist/client/blocks/library/specs.d.ts.map +1 -1
- package/dist/client/blocks/library/specs.js +30 -2
- package/dist/client/blocks/library/specs.js.map +1 -1
- package/dist/client/blocks/server.d.ts +1 -0
- package/dist/client/blocks/server.d.ts.map +1 -1
- package/dist/client/blocks/server.js +1 -0
- package/dist/client/blocks/server.js.map +1 -1
- package/dist/client/blocks/types.d.ts +1 -1
- package/dist/client/blocks/types.js.map +1 -1
- package/dist/client/extensions/ExtensionsListPage.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionsListPage.js +28 -13
- package/dist/client/extensions/ExtensionsListPage.js.map +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.js +31 -9
- package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
- package/dist/client/rich-markdown-editor/CodeBlockNode.d.ts +49 -0
- package/dist/client/rich-markdown-editor/CodeBlockNode.d.ts.map +1 -0
- package/dist/client/rich-markdown-editor/CodeBlockNode.js +126 -0
- package/dist/client/rich-markdown-editor/CodeBlockNode.js.map +1 -0
- package/dist/client/rich-markdown-editor/RegistryBlockNode.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js +26 -3
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
- package/dist/client/rich-markdown-editor/RichMarkdownEditor.d.ts +1 -1
- package/dist/client/rich-markdown-editor/extensions.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/extensions.js +8 -8
- package/dist/client/rich-markdown-editor/extensions.js.map +1 -1
- package/dist/client/rich-markdown-editor/index.d.ts +1 -0
- package/dist/client/rich-markdown-editor/index.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/index.js +1 -0
- package/dist/client/rich-markdown-editor/index.js.map +1 -1
- package/dist/client/rich-markdown-editor/registrySlashCommands.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/registrySlashCommands.js +1 -0
- package/dist/client/rich-markdown-editor/registrySlashCommands.js.map +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +63 -2
- package/dist/extensions/actions.js.map +1 -1
- package/dist/extensions/routes.d.ts.map +1 -1
- package/dist/extensions/routes.js +24 -3
- package/dist/extensions/routes.js.map +1 -1
- package/dist/extensions/schema.d.ts +43 -2
- package/dist/extensions/schema.d.ts.map +1 -1
- package/dist/extensions/schema.js +12 -0
- package/dist/extensions/schema.js.map +1 -1
- package/dist/extensions/store.d.ts +20 -0
- package/dist/extensions/store.d.ts.map +1 -1
- package/dist/extensions/store.js +82 -3
- package/dist/extensions/store.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +13 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +11 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/recap-image-route.d.ts +8 -0
- package/dist/server/recap-image-route.d.ts.map +1 -0
- package/dist/server/recap-image-route.js +200 -0
- package/dist/server/recap-image-route.js.map +1 -0
- package/dist/server/recap-image-store.d.ts +41 -0
- package/dist/server/recap-image-store.d.ts.map +1 -0
- package/dist/server/recap-image-store.js +138 -0
- package/dist/server/recap-image-store.js.map +1 -0
- package/dist/styles/rich-markdown-editor.css +66 -17
- package/dist/templates/default/pnpm-workspace.yaml +7 -0
- package/dist/templates/workspace-root/package.json +0 -5
- package/dist/templates/workspace-root/pnpm-workspace.yaml +14 -0
- package/docs/content/cloneable-saas.md +10 -0
- package/docs/content/external-agents.md +4 -7
- package/docs/content/faq.md +10 -0
- package/docs/content/getting-started.md +11 -0
- package/docs/content/pr-visual-recap.md +103 -0
- package/docs/content/skills-guide.md +1 -3
- package/docs/content/template-assets.md +1 -4
- package/docs/content/template-design.md +0 -57
- package/docs/content/template-plan.md +22 -18
- package/docs/content/visual-plans.md +10 -7
- package/docs/content/what-is-agent-native.md +2 -0
- package/package.json +5 -1
- package/src/templates/default/pnpm-workspace.yaml +7 -0
- package/src/templates/workspace-root/package.json +0 -5
- package/src/templates/workspace-root/pnpm-workspace.yaml +14 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined handler for the recap-image routes. Mount as a PREFIX handler at
|
|
3
|
+
* `/_agent-native/recap-image`; the framework strips the mount prefix, so:
|
|
4
|
+
* - `event.url.pathname === "/"` → POST upload (authenticated)
|
|
5
|
+
* - `event.url.pathname === "/<token>.png"` → GET/HEAD serve (anonymous)
|
|
6
|
+
*/
|
|
7
|
+
export declare function createRecapImageHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<unknown>>;
|
|
8
|
+
//# sourceMappingURL=recap-image-route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recap-image-route.d.ts","sourceRoot":"","sources":["../../src/server/recap-image-route.ts"],"names":[],"mappings":"AA+MA;;;;;GAKG;AACH,wBAAgB,uBAAuB,2FAoBtC"}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes for signed, content-only recap PNG images.
|
|
3
|
+
*
|
|
4
|
+
* POST /_agent-native/recap-image
|
|
5
|
+
* Auth: `Authorization: Bearer <token>` — accepts the SAME tokens the MCP /
|
|
6
|
+
* action surface accepts: a legacy `sessions` bearer (desktop/native) OR a
|
|
7
|
+
* connect-minted MCP OAuth access token (the `agent-native connect` token,
|
|
8
|
+
* audience-bound to this app's `{origin}/_agent-native/mcp` resource). A
|
|
9
|
+
* normal browser session cookie is also accepted. Rejects unauthenticated
|
|
10
|
+
* callers with 401.
|
|
11
|
+
* Body: raw `image/png` bytes, or JSON `{ "pngBase64": "..." }`. Capped at
|
|
12
|
+
* ~5 MB. Stores the PNG and returns `{ imageUrl: "<origin>/_agent-native/
|
|
13
|
+
* recap-image/<token>.png" }`.
|
|
14
|
+
*
|
|
15
|
+
* GET /_agent-native/recap-image/<token>.png
|
|
16
|
+
* ANONYMOUS (no auth) so GitHub's camo image proxy can fetch it into a
|
|
17
|
+
* private-repo PR comment. Returns the stored PNG with a strict
|
|
18
|
+
* `Content-Type: image/png` and a long immutable cache header. 404 on an
|
|
19
|
+
* unknown/malformed token. Only ever serves opaque image bytes — no plan
|
|
20
|
+
* data leaks through this route.
|
|
21
|
+
*/
|
|
22
|
+
import { defineEventHandler, getHeader, getMethod, readRawBody, setResponseHeader, setResponseStatus, } from "h3";
|
|
23
|
+
import { getSession } from "./auth.js";
|
|
24
|
+
import { getAppUrl } from "./google-oauth.js";
|
|
25
|
+
import { RECAP_IMAGE_CONTENT_TYPE, RECAP_IMAGE_MAX_BYTES, getRecapImage, isValidRecapImageToken, saveRecapImage, } from "./recap-image-store.js";
|
|
26
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
27
|
+
/** Long immutable cache — the bytes for a given token never change. */
|
|
28
|
+
const RECAP_IMAGE_CACHE_CONTROL = "public, max-age=31536000, immutable, stale-while-revalidate=604800, stale-if-error=86400";
|
|
29
|
+
function isPngBuffer(buf) {
|
|
30
|
+
return (buf.byteLength >= PNG_MAGIC.byteLength &&
|
|
31
|
+
buf.subarray(0, 8).equals(PNG_MAGIC));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a session for the upload route. Reuses the SAME acceptance the MCP /
|
|
35
|
+
* action surface uses:
|
|
36
|
+
* 1. `getSession(event)` — browser cookie, ACCESS_TOKEN, and legacy bearer
|
|
37
|
+
* (`sessions` table) tokens.
|
|
38
|
+
* 2. A connect-minted MCP OAuth access token, verified through the MCP
|
|
39
|
+
* surface's canonical `verifyAuth` with this app's MCP resource as the
|
|
40
|
+
* expected audience and `allowDevOpen: false`. `getSession` only honors
|
|
41
|
+
* this token on the `/_agent-native/actions/*` surface, so we mirror that
|
|
42
|
+
* verification here for the recap-image upload route.
|
|
43
|
+
*/
|
|
44
|
+
async function resolveUploadSession(event) {
|
|
45
|
+
const session = await getSession(event).catch(() => null);
|
|
46
|
+
if (session?.email)
|
|
47
|
+
return session;
|
|
48
|
+
// Trim once and reuse the trimmed value everywhere. Match verifyAuth's check
|
|
49
|
+
// exactly — a literal, case-sensitive `Bearer ` prefix (not `/i`, not `\s+`) —
|
|
50
|
+
// so this pre-check never accepts a header that verifyAuth would then reject
|
|
51
|
+
// (e.g. lowercase `bearer` or a tab separator).
|
|
52
|
+
const authHeader = getHeader(event, "authorization")?.trim();
|
|
53
|
+
if (!authHeader || !/^Bearer \S/.test(authHeader))
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const [{ getMcpOAuthResource }, { verifyAuth, resolveOrgIdFromDomain }] = await Promise.all([
|
|
57
|
+
import("../mcp/oauth-route.js"),
|
|
58
|
+
import("../mcp/build-server.js"),
|
|
59
|
+
]);
|
|
60
|
+
const result = await verifyAuth(authHeader, undefined, {
|
|
61
|
+
resourceUrl: getMcpOAuthResource(event),
|
|
62
|
+
allowDevOpen: false,
|
|
63
|
+
});
|
|
64
|
+
const identity = result.authed ? result.identity : undefined;
|
|
65
|
+
if (!identity?.userEmail)
|
|
66
|
+
return null;
|
|
67
|
+
const orgId = identity.orgId ?? (await resolveOrgIdFromDomain(identity.orgDomain));
|
|
68
|
+
return {
|
|
69
|
+
email: identity.userEmail,
|
|
70
|
+
token: authHeader.replace(/^Bearer /, "").trim(),
|
|
71
|
+
...(orgId ? { orgId } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.error("[recap-image] bearer verification error:", error);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Extract PNG bytes from the request. Supports raw `image/png` bytes and JSON
|
|
81
|
+
* `{ pngBase64 }`. Returns `null` on a malformed/oversized/non-PNG payload.
|
|
82
|
+
*/
|
|
83
|
+
async function readPngFromRequest(event) {
|
|
84
|
+
const raw = await readRawBody(event, false).catch(() => undefined);
|
|
85
|
+
if (!raw || raw.byteLength === 0)
|
|
86
|
+
return null;
|
|
87
|
+
if (raw.byteLength > RECAP_IMAGE_MAX_BYTES)
|
|
88
|
+
return null;
|
|
89
|
+
const contentType = (getHeader(event, "content-type") || "").toLowerCase();
|
|
90
|
+
if (contentType.includes("application/json")) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const base64 = parsed?.pngBase64;
|
|
99
|
+
if (typeof base64 !== "string" || !base64)
|
|
100
|
+
return null;
|
|
101
|
+
let bytes;
|
|
102
|
+
try {
|
|
103
|
+
bytes = Buffer.from(base64, "base64");
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (bytes.byteLength === 0 || bytes.byteLength > RECAP_IMAGE_MAX_BYTES) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return isPngBuffer(bytes) ? bytes : null;
|
|
112
|
+
}
|
|
113
|
+
// Default: treat the raw body as PNG bytes (image/png or unspecified).
|
|
114
|
+
return isPngBuffer(raw) ? raw : null;
|
|
115
|
+
}
|
|
116
|
+
/** POST /_agent-native/recap-image — authenticated upload. */
|
|
117
|
+
async function handleUpload(event) {
|
|
118
|
+
const session = await resolveUploadSession(event);
|
|
119
|
+
if (!session?.email) {
|
|
120
|
+
setResponseStatus(event, 401);
|
|
121
|
+
return { error: "Authentication required" };
|
|
122
|
+
}
|
|
123
|
+
const png = await readPngFromRequest(event);
|
|
124
|
+
if (!png) {
|
|
125
|
+
setResponseStatus(event, 400);
|
|
126
|
+
return {
|
|
127
|
+
error: "Expected a PNG image (Content-Type: image/png raw bytes, or JSON { pngBase64 }), at most 5 MB.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const { token } = await saveRecapImage(png, { ownerEmail: session.email });
|
|
132
|
+
const imageUrl = getAppUrl(event, `/_agent-native/recap-image/${token}.png`);
|
|
133
|
+
setResponseStatus(event, 201);
|
|
134
|
+
return { imageUrl };
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.error("[recap-image] failed to store image:", error);
|
|
138
|
+
setResponseStatus(event, 500);
|
|
139
|
+
return { error: "Failed to store recap image" };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** GET/HEAD /_agent-native/recap-image/<token>.png — anonymous, content-only. */
|
|
143
|
+
async function handleServe(event, segment) {
|
|
144
|
+
// Require the strict `<hex>.png` shape — no directory traversal, no
|
|
145
|
+
// alternate extensions, no extra path segments.
|
|
146
|
+
const match = /^([0-9a-f]+)\.png$/i.exec(segment);
|
|
147
|
+
const token = match?.[1]?.toLowerCase() ?? "";
|
|
148
|
+
if (!isValidRecapImageToken(token)) {
|
|
149
|
+
setResponseStatus(event, 404);
|
|
150
|
+
return { error: "Not found" };
|
|
151
|
+
}
|
|
152
|
+
const stored = await getRecapImage(token).catch(() => null);
|
|
153
|
+
if (!stored) {
|
|
154
|
+
setResponseStatus(event, 404);
|
|
155
|
+
return { error: "Not found" };
|
|
156
|
+
}
|
|
157
|
+
// Strict image/png on read regardless of what was stored, plus a long
|
|
158
|
+
// immutable cache and a cross-origin policy so the camo proxy can fetch it.
|
|
159
|
+
const headers = {
|
|
160
|
+
"Content-Type": RECAP_IMAGE_CONTENT_TYPE,
|
|
161
|
+
"Cache-Control": RECAP_IMAGE_CACHE_CONTROL,
|
|
162
|
+
"CDN-Cache-Control": RECAP_IMAGE_CACHE_CONTROL,
|
|
163
|
+
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
164
|
+
"Content-Length": String(stored.bytes.byteLength),
|
|
165
|
+
};
|
|
166
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
167
|
+
setResponseHeader(event, name, value);
|
|
168
|
+
}
|
|
169
|
+
if (getMethod(event) === "HEAD")
|
|
170
|
+
return "";
|
|
171
|
+
const body = new ArrayBuffer(stored.bytes.byteLength);
|
|
172
|
+
new Uint8Array(body).set(stored.bytes);
|
|
173
|
+
return new Response(body, { headers });
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Combined handler for the recap-image routes. Mount as a PREFIX handler at
|
|
177
|
+
* `/_agent-native/recap-image`; the framework strips the mount prefix, so:
|
|
178
|
+
* - `event.url.pathname === "/"` → POST upload (authenticated)
|
|
179
|
+
* - `event.url.pathname === "/<token>.png"` → GET/HEAD serve (anonymous)
|
|
180
|
+
*/
|
|
181
|
+
export function createRecapImageHandler() {
|
|
182
|
+
return defineEventHandler(async (event) => {
|
|
183
|
+
const segment = (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] || "";
|
|
184
|
+
const method = getMethod(event);
|
|
185
|
+
if (!segment) {
|
|
186
|
+
if (method === "POST")
|
|
187
|
+
return handleUpload(event);
|
|
188
|
+
setResponseStatus(event, 405);
|
|
189
|
+
setResponseHeader(event, "Allow", "POST");
|
|
190
|
+
return { error: "Method not allowed" };
|
|
191
|
+
}
|
|
192
|
+
if (method === "GET" || method === "HEAD") {
|
|
193
|
+
return handleServe(event, segment);
|
|
194
|
+
}
|
|
195
|
+
setResponseStatus(event, 405);
|
|
196
|
+
setResponseHeader(event, "Allow", "GET, HEAD");
|
|
197
|
+
return { error: "Method not allowed" };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
//# sourceMappingURL=recap-image-route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recap-image-route.js","sourceRoot":"","sources":["../../src/server/recap-image-route.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Routes for signed, content-only recap PNG images.\n *\n * POST /_agent-native/recap-image\n * Auth: `Authorization: Bearer <token>` — accepts the SAME tokens the MCP /\n * action surface accepts: a legacy `sessions` bearer (desktop/native) OR a\n * connect-minted MCP OAuth access token (the `agent-native connect` token,\n * audience-bound to this app's `{origin}/_agent-native/mcp` resource). A\n * normal browser session cookie is also accepted. Rejects unauthenticated\n * callers with 401.\n * Body: raw `image/png` bytes, or JSON `{ \"pngBase64\": \"...\" }`. Capped at\n * ~5 MB. Stores the PNG and returns `{ imageUrl: \"<origin>/_agent-native/\n * recap-image/<token>.png\" }`.\n *\n * GET /_agent-native/recap-image/<token>.png\n * ANONYMOUS (no auth) so GitHub's camo image proxy can fetch it into a\n * private-repo PR comment. Returns the stored PNG with a strict\n * `Content-Type: image/png` and a long immutable cache header. 404 on an\n * unknown/malformed token. Only ever serves opaque image bytes — no plan\n * data leaks through this route.\n */\nimport {\n defineEventHandler,\n getHeader,\n getMethod,\n readRawBody,\n setResponseHeader,\n setResponseStatus,\n type H3Event,\n} from \"h3\";\nimport { getSession, type AuthSession } from \"./auth.js\";\nimport { getAppUrl } from \"./google-oauth.js\";\nimport {\n RECAP_IMAGE_CONTENT_TYPE,\n RECAP_IMAGE_MAX_BYTES,\n getRecapImage,\n isValidRecapImageToken,\n saveRecapImage,\n} from \"./recap-image-store.js\";\n\nconst PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\n\n/** Long immutable cache — the bytes for a given token never change. */\nconst RECAP_IMAGE_CACHE_CONTROL =\n \"public, max-age=31536000, immutable, stale-while-revalidate=604800, stale-if-error=86400\";\n\nfunction isPngBuffer(buf: Buffer): boolean {\n return (\n buf.byteLength >= PNG_MAGIC.byteLength &&\n buf.subarray(0, 8).equals(PNG_MAGIC)\n );\n}\n\n/**\n * Resolve a session for the upload route. Reuses the SAME acceptance the MCP /\n * action surface uses:\n * 1. `getSession(event)` — browser cookie, ACCESS_TOKEN, and legacy bearer\n * (`sessions` table) tokens.\n * 2. A connect-minted MCP OAuth access token, verified through the MCP\n * surface's canonical `verifyAuth` with this app's MCP resource as the\n * expected audience and `allowDevOpen: false`. `getSession` only honors\n * this token on the `/_agent-native/actions/*` surface, so we mirror that\n * verification here for the recap-image upload route.\n */\nasync function resolveUploadSession(\n event: H3Event,\n): Promise<AuthSession | null> {\n const session = await getSession(event).catch(() => null);\n if (session?.email) return session;\n\n // Trim once and reuse the trimmed value everywhere. Match verifyAuth's check\n // exactly — a literal, case-sensitive `Bearer ` prefix (not `/i`, not `\\s+`) —\n // so this pre-check never accepts a header that verifyAuth would then reject\n // (e.g. lowercase `bearer` or a tab separator).\n const authHeader = getHeader(event, \"authorization\")?.trim();\n if (!authHeader || !/^Bearer \\S/.test(authHeader)) return null;\n\n try {\n const [{ getMcpOAuthResource }, { verifyAuth, resolveOrgIdFromDomain }] =\n await Promise.all([\n import(\"../mcp/oauth-route.js\"),\n import(\"../mcp/build-server.js\"),\n ]);\n const result = await verifyAuth(authHeader, undefined, {\n resourceUrl: getMcpOAuthResource(event),\n allowDevOpen: false,\n });\n const identity = result.authed ? result.identity : undefined;\n if (!identity?.userEmail) return null;\n const orgId =\n identity.orgId ?? (await resolveOrgIdFromDomain(identity.orgDomain));\n return {\n email: identity.userEmail,\n token: authHeader.replace(/^Bearer /, \"\").trim(),\n ...(orgId ? { orgId } : {}),\n };\n } catch (error) {\n console.error(\"[recap-image] bearer verification error:\", error);\n return null;\n }\n}\n\n/**\n * Extract PNG bytes from the request. Supports raw `image/png` bytes and JSON\n * `{ pngBase64 }`. Returns `null` on a malformed/oversized/non-PNG payload.\n */\nasync function readPngFromRequest(event: H3Event): Promise<Buffer | null> {\n const raw = await readRawBody(event, false).catch(() => undefined);\n if (!raw || raw.byteLength === 0) return null;\n if (raw.byteLength > RECAP_IMAGE_MAX_BYTES) return null;\n\n const contentType = (getHeader(event, \"content-type\") || \"\").toLowerCase();\n\n if (contentType.includes(\"application/json\")) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw.toString(\"utf8\"));\n } catch {\n return null;\n }\n const base64 = (parsed as { pngBase64?: unknown })?.pngBase64;\n if (typeof base64 !== \"string\" || !base64) return null;\n let bytes: Buffer;\n try {\n bytes = Buffer.from(base64, \"base64\");\n } catch {\n return null;\n }\n if (bytes.byteLength === 0 || bytes.byteLength > RECAP_IMAGE_MAX_BYTES) {\n return null;\n }\n return isPngBuffer(bytes) ? bytes : null;\n }\n\n // Default: treat the raw body as PNG bytes (image/png or unspecified).\n return isPngBuffer(raw) ? raw : null;\n}\n\n/** POST /_agent-native/recap-image — authenticated upload. */\nasync function handleUpload(event: H3Event): Promise<unknown> {\n const session = await resolveUploadSession(event);\n if (!session?.email) {\n setResponseStatus(event, 401);\n return { error: \"Authentication required\" };\n }\n\n const png = await readPngFromRequest(event);\n if (!png) {\n setResponseStatus(event, 400);\n return {\n error:\n \"Expected a PNG image (Content-Type: image/png raw bytes, or JSON { pngBase64 }), at most 5 MB.\",\n };\n }\n\n try {\n const { token } = await saveRecapImage(png, { ownerEmail: session.email });\n const imageUrl = getAppUrl(\n event,\n `/_agent-native/recap-image/${token}.png`,\n );\n setResponseStatus(event, 201);\n return { imageUrl };\n } catch (error) {\n console.error(\"[recap-image] failed to store image:\", error);\n setResponseStatus(event, 500);\n return { error: \"Failed to store recap image\" };\n }\n}\n\n/** GET/HEAD /_agent-native/recap-image/<token>.png — anonymous, content-only. */\nasync function handleServe(event: H3Event, segment: string): Promise<unknown> {\n // Require the strict `<hex>.png` shape — no directory traversal, no\n // alternate extensions, no extra path segments.\n const match = /^([0-9a-f]+)\\.png$/i.exec(segment);\n const token = match?.[1]?.toLowerCase() ?? \"\";\n if (!isValidRecapImageToken(token)) {\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n }\n\n const stored = await getRecapImage(token).catch(() => null);\n if (!stored) {\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n }\n\n // Strict image/png on read regardless of what was stored, plus a long\n // immutable cache and a cross-origin policy so the camo proxy can fetch it.\n const headers: Record<string, string> = {\n \"Content-Type\": RECAP_IMAGE_CONTENT_TYPE,\n \"Cache-Control\": RECAP_IMAGE_CACHE_CONTROL,\n \"CDN-Cache-Control\": RECAP_IMAGE_CACHE_CONTROL,\n \"Cross-Origin-Resource-Policy\": \"cross-origin\",\n \"Content-Length\": String(stored.bytes.byteLength),\n };\n for (const [name, value] of Object.entries(headers)) {\n setResponseHeader(event, name, value);\n }\n\n if (getMethod(event) === \"HEAD\") return \"\";\n\n const body = new ArrayBuffer(stored.bytes.byteLength);\n new Uint8Array(body).set(stored.bytes);\n return new Response(body, { headers });\n}\n\n/**\n * Combined handler for the recap-image routes. Mount as a PREFIX handler at\n * `/_agent-native/recap-image`; the framework strips the mount prefix, so:\n * - `event.url.pathname === \"/\"` → POST upload (authenticated)\n * - `event.url.pathname === \"/<token>.png\"` → GET/HEAD serve (anonymous)\n */\nexport function createRecapImageHandler() {\n return defineEventHandler(async (event: H3Event) => {\n const segment =\n (event.url?.pathname || \"\").replace(/^\\/+/, \"\").split(\"/\")[0] || \"\";\n const method = getMethod(event);\n\n if (!segment) {\n if (method === \"POST\") return handleUpload(event);\n setResponseStatus(event, 405);\n setResponseHeader(event, \"Allow\", \"POST\");\n return { error: \"Method not allowed\" };\n }\n\n if (method === \"GET\" || method === \"HEAD\") {\n return handleServe(event, segment);\n }\n setResponseStatus(event, 405);\n setResponseHeader(event, \"Allow\", \"GET, HEAD\");\n return { error: \"Method not allowed\" };\n });\n}\n"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Maximum stored image size (~5 MB of raw PNG bytes). */
|
|
2
|
+
export declare const RECAP_IMAGE_MAX_BYTES: number;
|
|
3
|
+
/** Only `image/png` is ever stored or served. */
|
|
4
|
+
export declare const RECAP_IMAGE_CONTENT_TYPE = "image/png";
|
|
5
|
+
/**
|
|
6
|
+
* Stored recap images older than this are pruned on the next write (30 days).
|
|
7
|
+
* Each PR push uploads a fresh screenshot under a new token; without expiry the
|
|
8
|
+
* table — and the set of anonymously-fetchable image URLs — would grow without
|
|
9
|
+
* bound. 30 days comfortably outlives any PR's review window.
|
|
10
|
+
*/
|
|
11
|
+
export declare const RECAP_IMAGE_TTL_MS: number;
|
|
12
|
+
export declare function ensureRecapImageTable(): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Delete recap images older than {@link RECAP_IMAGE_TTL_MS}. Called best-effort
|
|
15
|
+
* after each write so the table stays bounded. Returns the number of rows
|
|
16
|
+
* removed. Dialect-agnostic (a plain `DELETE ... WHERE created_at < ?`).
|
|
17
|
+
*/
|
|
18
|
+
export declare function pruneExpiredRecapImages(now?: number): Promise<number>;
|
|
19
|
+
/** Generate a long, unguessable lowercase-hex token (default 32 bytes → 64 hex chars). */
|
|
20
|
+
export declare function generateRecapImageToken(byteLength?: number): string;
|
|
21
|
+
/** True when `token` matches the strict hex token format (no traversal characters). */
|
|
22
|
+
export declare function isValidRecapImageToken(token: string | undefined | null): boolean;
|
|
23
|
+
export interface StoredRecapImage {
|
|
24
|
+
bytes: Buffer;
|
|
25
|
+
contentType: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Store PNG bytes and return the freshly minted token. Caller is responsible
|
|
29
|
+
* for enforcing the size cap before calling (we re-check defensively here too).
|
|
30
|
+
*/
|
|
31
|
+
export declare function saveRecapImage(png: Buffer, options?: {
|
|
32
|
+
ownerEmail?: string | null;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
token: string;
|
|
35
|
+
}>;
|
|
36
|
+
/**
|
|
37
|
+
* Load a stored image by token. Returns `null` for an unknown or malformed
|
|
38
|
+
* token. Never returns anything but the opaque image bytes — no plan data.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getRecapImage(token: string): Promise<StoredRecapImage | null>;
|
|
41
|
+
//# sourceMappingURL=recap-image-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recap-image-store.d.ts","sourceRoot":"","sources":["../../src/server/recap-image-store.ts"],"names":[],"mappings":"AAsBA,0DAA0D;AAC1D,eAAO,MAAM,qBAAqB,QAAkB,CAAC;AAErD,iDAAiD;AACjD,eAAO,MAAM,wBAAwB,cAAc,CAAC;AAEpD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,QAA2B,CAAC;AAW3D,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAuB3D;AAED;;;;GAIG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,GAAE,MAAmB,GACvB,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED,0FAA0F;AAC1F,wBAAgB,uBAAuB,CAAC,UAAU,SAAK,GAAG,MAAM,CAE/D;AAED,uFAAuF;AACvF,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAC/B,OAAO,CAET;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAO,GAC3C,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAwB5B;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAqBlC"}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL persistence for signed, content-only recap PNG images.
|
|
3
|
+
*
|
|
4
|
+
* The PR visual-recap GitHub Action renders a recap plan to a PNG and uploads
|
|
5
|
+
* it through `POST /_agent-native/recap-image` (authenticated with the same
|
|
6
|
+
* `agent-native connect` bearer token the MCP / action surface accepts). The
|
|
7
|
+
* stored bytes are then served anonymously from
|
|
8
|
+
* `GET /_agent-native/recap-image/<token>.png` so GitHub's camo image proxy can
|
|
9
|
+
* fetch them into a (private-repo) PR comment without a login. The interactive
|
|
10
|
+
* plan itself stays login-gated; this store only ever holds opaque image bytes
|
|
11
|
+
* keyed by a long, unguessable token.
|
|
12
|
+
*
|
|
13
|
+
* Follows the same raw-SQL pattern as observability/store.ts and usage/store.ts
|
|
14
|
+
* — framework-owned tables use `getDbExec()` with dialect-agnostic
|
|
15
|
+
* `CREATE TABLE IF NOT EXISTS` DDL (additive only; never drops/renames/alters)
|
|
16
|
+
* rather than Drizzle ORM, which is reserved for template-level schemas. The
|
|
17
|
+
* PNG is stored as base64 TEXT so it is portable across SQLite, Neon/Postgres,
|
|
18
|
+
* libSQL/Turso, and D1 without per-dialect blob/bytea handling.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { getDbExec, intType, retryOnDdlRace } from "../db/client.js";
|
|
22
|
+
/** Maximum stored image size (~5 MB of raw PNG bytes). */
|
|
23
|
+
export const RECAP_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
/** Only `image/png` is ever stored or served. */
|
|
25
|
+
export const RECAP_IMAGE_CONTENT_TYPE = "image/png";
|
|
26
|
+
/**
|
|
27
|
+
* Stored recap images older than this are pruned on the next write (30 days).
|
|
28
|
+
* Each PR push uploads a fresh screenshot under a new token; without expiry the
|
|
29
|
+
* table — and the set of anonymously-fetchable image URLs — would grow without
|
|
30
|
+
* bound. 30 days comfortably outlives any PR's review window.
|
|
31
|
+
*/
|
|
32
|
+
export const RECAP_IMAGE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
33
|
+
/**
|
|
34
|
+
* Token format for the public `<token>.png` path. Hex-only so it can never
|
|
35
|
+
* contain a path separator, `.`, or `..` — no directory traversal is possible
|
|
36
|
+
* via the token path param.
|
|
37
|
+
*/
|
|
38
|
+
const TOKEN_PATTERN = /^[0-9a-f]{32,128}$/;
|
|
39
|
+
let _initPromise;
|
|
40
|
+
export async function ensureRecapImageTable() {
|
|
41
|
+
if (!_initPromise) {
|
|
42
|
+
_initPromise = (async () => {
|
|
43
|
+
const client = getDbExec();
|
|
44
|
+
await retryOnDdlRace(() => client.execute(`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS recap_images (
|
|
46
|
+
token TEXT PRIMARY KEY,
|
|
47
|
+
png_base64 TEXT NOT NULL,
|
|
48
|
+
content_type TEXT NOT NULL DEFAULT '${RECAP_IMAGE_CONTENT_TYPE}',
|
|
49
|
+
byte_length ${intType()} NOT NULL DEFAULT 0,
|
|
50
|
+
owner_email TEXT,
|
|
51
|
+
created_at ${intType()} NOT NULL
|
|
52
|
+
)
|
|
53
|
+
`));
|
|
54
|
+
})().catch((error) => {
|
|
55
|
+
// Allow a later call to retry if the first init lost a DDL race.
|
|
56
|
+
_initPromise = undefined;
|
|
57
|
+
throw error;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return _initPromise;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Delete recap images older than {@link RECAP_IMAGE_TTL_MS}. Called best-effort
|
|
64
|
+
* after each write so the table stays bounded. Returns the number of rows
|
|
65
|
+
* removed. Dialect-agnostic (a plain `DELETE ... WHERE created_at < ?`).
|
|
66
|
+
*/
|
|
67
|
+
export async function pruneExpiredRecapImages(now = Date.now()) {
|
|
68
|
+
await ensureRecapImageTable();
|
|
69
|
+
const client = getDbExec();
|
|
70
|
+
const { rowsAffected } = await client.execute({
|
|
71
|
+
sql: `DELETE FROM recap_images WHERE created_at < ?`,
|
|
72
|
+
args: [now - RECAP_IMAGE_TTL_MS],
|
|
73
|
+
});
|
|
74
|
+
return rowsAffected;
|
|
75
|
+
}
|
|
76
|
+
/** Generate a long, unguessable lowercase-hex token (default 32 bytes → 64 hex chars). */
|
|
77
|
+
export function generateRecapImageToken(byteLength = 32) {
|
|
78
|
+
return randomBytes(byteLength).toString("hex");
|
|
79
|
+
}
|
|
80
|
+
/** True when `token` matches the strict hex token format (no traversal characters). */
|
|
81
|
+
export function isValidRecapImageToken(token) {
|
|
82
|
+
return typeof token === "string" && TOKEN_PATTERN.test(token);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Store PNG bytes and return the freshly minted token. Caller is responsible
|
|
86
|
+
* for enforcing the size cap before calling (we re-check defensively here too).
|
|
87
|
+
*/
|
|
88
|
+
export async function saveRecapImage(png, options = {}) {
|
|
89
|
+
if (png.byteLength > RECAP_IMAGE_MAX_BYTES) {
|
|
90
|
+
throw new Error("recap image exceeds maximum size");
|
|
91
|
+
}
|
|
92
|
+
await ensureRecapImageTable();
|
|
93
|
+
const client = getDbExec();
|
|
94
|
+
const token = generateRecapImageToken();
|
|
95
|
+
await client.execute({
|
|
96
|
+
sql: `INSERT INTO recap_images
|
|
97
|
+
(token, png_base64, content_type, byte_length, owner_email, created_at)
|
|
98
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
99
|
+
args: [
|
|
100
|
+
token,
|
|
101
|
+
png.toString("base64"),
|
|
102
|
+
RECAP_IMAGE_CONTENT_TYPE,
|
|
103
|
+
png.byteLength,
|
|
104
|
+
options.ownerEmail ?? null,
|
|
105
|
+
Date.now(),
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
// Best-effort retention: expire old images so the table and the set of public
|
|
109
|
+
// image URLs stay bounded. Never let a cleanup failure fail the upload.
|
|
110
|
+
await pruneExpiredRecapImages().catch(() => { });
|
|
111
|
+
return { token };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Load a stored image by token. Returns `null` for an unknown or malformed
|
|
115
|
+
* token. Never returns anything but the opaque image bytes — no plan data.
|
|
116
|
+
*/
|
|
117
|
+
export async function getRecapImage(token) {
|
|
118
|
+
if (!isValidRecapImageToken(token))
|
|
119
|
+
return null;
|
|
120
|
+
await ensureRecapImageTable();
|
|
121
|
+
const client = getDbExec();
|
|
122
|
+
const { rows } = await client.execute({
|
|
123
|
+
sql: `SELECT png_base64, content_type FROM recap_images WHERE token = ? LIMIT 1`,
|
|
124
|
+
args: [token],
|
|
125
|
+
});
|
|
126
|
+
const row = rows[0];
|
|
127
|
+
if (!row || typeof row.png_base64 !== "string")
|
|
128
|
+
return null;
|
|
129
|
+
return {
|
|
130
|
+
bytes: Buffer.from(row.png_base64, "base64"),
|
|
131
|
+
// Stored content type is always image/png; never trust it for response
|
|
132
|
+
// headers — the route hard-codes image/png — but surface it for callers.
|
|
133
|
+
contentType: typeof row.content_type === "string"
|
|
134
|
+
? row.content_type
|
|
135
|
+
: RECAP_IMAGE_CONTENT_TYPE,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=recap-image-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recap-image-store.js","sourceRoot":"","sources":["../../src/server/recap-image-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAErE,0DAA0D;AAC1D,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAErD,iDAAiD;AACjD,MAAM,CAAC,MAAM,wBAAwB,GAAG,WAAW,CAAC;AAEpD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAE3D;;;;GAIG;AACH,MAAM,aAAa,GAAG,oBAAoB,CAAC;AAE3C,IAAI,YAAuC,CAAC;AAE5C,MAAM,CAAC,KAAK,UAAU,qBAAqB;IACzC,IAAI,CAAC,YAAY,EAAE,CAAC;QAClB,YAAY,GAAG,CAAC,KAAK,IAAI,EAAE;YACzB,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;YAC3B,MAAM,cAAc,CAAC,GAAG,EAAE,CACxB,MAAM,CAAC,OAAO,CAAC;;;;gDAIyB,wBAAwB;wBAChD,OAAO,EAAE;;uBAEV,OAAO,EAAE;;OAEzB,CAAC,CACD,CAAC;QACJ,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;YACnB,iEAAiE;YACjE,YAAY,GAAG,SAAS,CAAC;YACzB,MAAM,KAAK,CAAC;QACd,CAAC,CAAC,CAAC;IACL,CAAC;IACD,OAAO,YAAY,CAAC;AACtB,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,MAAc,IAAI,CAAC,GAAG,EAAE;IAExB,MAAM,qBAAqB,EAAE,CAAC;IAC9B,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;IAC3B,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC;QAC5C,GAAG,EAAE,+CAA+C;QACpD,IAAI,EAAE,CAAC,GAAG,GAAG,kBAAkB,CAAC;KACjC,CAAC,CAAC;IACH,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,uBAAuB,CAAC,UAAU,GAAG,EAAE;IACrD,OAAO,WAAW,CAAC,UAAU,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AACjD,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,sBAAsB,CACpC,KAAgC;IAEhC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAChE,CAAC;AAOD;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAW,EACX,UAA0C,EAAE;IAE5C,IAAI,GAAG,CAAC,UAAU,GAAG,qBAAqB,EAAE,CAAC;QAC3C,MAAM,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC;IACtD,CAAC;IACD,MAAM,qBAAqB,EAAE,CAAC;IAC9B,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;IAC3B,MAAM,KAAK,GAAG,uBAAuB,EAAE,CAAC;IACxC,MAAM,MAAM,CAAC,OAAO,CAAC;QACnB,GAAG,EAAE;;gCAEuB;QAC5B,IAAI,EAAE;YACJ,KAAK;YACL,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC;YACtB,wBAAwB;YACxB,GAAG,CAAC,UAAU;YACd,OAAO,CAAC,UAAU,IAAI,IAAI;YAC1B,IAAI,CAAC,GAAG,EAAE;SACX;KACF,CAAC,CAAC;IACH,8EAA8E;IAC9E,wEAAwE;IACxE,MAAM,uBAAuB,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IAChD,OAAO,EAAE,KAAK,EAAE,CAAC;AACnB,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,KAAa;IAEb,IAAI,CAAC,sBAAsB,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,MAAM,qBAAqB,EAAE,CAAC;IAC9B,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;IAC3B,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC;QACpC,GAAG,EAAE,2EAA2E;QAChF,IAAI,EAAE,CAAC,KAAK,CAAC;KACd,CAAC,CAAC;IACH,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAEL,CAAC;IACd,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,CAAC,UAAU,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC5D,OAAO;QACL,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,QAAQ,CAAC;QAC5C,uEAAuE;QACvE,yEAAyE;QACzE,WAAW,EACT,OAAO,GAAG,CAAC,YAAY,KAAK,QAAQ;YAClC,CAAC,CAAC,GAAG,CAAC,YAAY;YAClB,CAAC,CAAC,wBAAwB;KAC/B,CAAC;AACJ,CAAC","sourcesContent":["/**\n * SQL persistence for signed, content-only recap PNG images.\n *\n * The PR visual-recap GitHub Action renders a recap plan to a PNG and uploads\n * it through `POST /_agent-native/recap-image` (authenticated with the same\n * `agent-native connect` bearer token the MCP / action surface accepts). The\n * stored bytes are then served anonymously from\n * `GET /_agent-native/recap-image/<token>.png` so GitHub's camo image proxy can\n * fetch them into a (private-repo) PR comment without a login. The interactive\n * plan itself stays login-gated; this store only ever holds opaque image bytes\n * keyed by a long, unguessable token.\n *\n * Follows the same raw-SQL pattern as observability/store.ts and usage/store.ts\n * — framework-owned tables use `getDbExec()` with dialect-agnostic\n * `CREATE TABLE IF NOT EXISTS` DDL (additive only; never drops/renames/alters)\n * rather than Drizzle ORM, which is reserved for template-level schemas. The\n * PNG is stored as base64 TEXT so it is portable across SQLite, Neon/Postgres,\n * libSQL/Turso, and D1 without per-dialect blob/bytea handling.\n */\nimport { randomBytes } from \"node:crypto\";\nimport { getDbExec, intType, retryOnDdlRace } from \"../db/client.js\";\n\n/** Maximum stored image size (~5 MB of raw PNG bytes). */\nexport const RECAP_IMAGE_MAX_BYTES = 5 * 1024 * 1024;\n\n/** Only `image/png` is ever stored or served. */\nexport const RECAP_IMAGE_CONTENT_TYPE = \"image/png\";\n\n/**\n * Stored recap images older than this are pruned on the next write (30 days).\n * Each PR push uploads a fresh screenshot under a new token; without expiry the\n * table — and the set of anonymously-fetchable image URLs — would grow without\n * bound. 30 days comfortably outlives any PR's review window.\n */\nexport const RECAP_IMAGE_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * Token format for the public `<token>.png` path. Hex-only so it can never\n * contain a path separator, `.`, or `..` — no directory traversal is possible\n * via the token path param.\n */\nconst TOKEN_PATTERN = /^[0-9a-f]{32,128}$/;\n\nlet _initPromise: Promise<void> | undefined;\n\nexport async function ensureRecapImageTable(): Promise<void> {\n if (!_initPromise) {\n _initPromise = (async () => {\n const client = getDbExec();\n await retryOnDdlRace(() =>\n client.execute(`\n CREATE TABLE IF NOT EXISTS recap_images (\n token TEXT PRIMARY KEY,\n png_base64 TEXT NOT NULL,\n content_type TEXT NOT NULL DEFAULT '${RECAP_IMAGE_CONTENT_TYPE}',\n byte_length ${intType()} NOT NULL DEFAULT 0,\n owner_email TEXT,\n created_at ${intType()} NOT NULL\n )\n `),\n );\n })().catch((error) => {\n // Allow a later call to retry if the first init lost a DDL race.\n _initPromise = undefined;\n throw error;\n });\n }\n return _initPromise;\n}\n\n/**\n * Delete recap images older than {@link RECAP_IMAGE_TTL_MS}. Called best-effort\n * after each write so the table stays bounded. Returns the number of rows\n * removed. Dialect-agnostic (a plain `DELETE ... WHERE created_at < ?`).\n */\nexport async function pruneExpiredRecapImages(\n now: number = Date.now(),\n): Promise<number> {\n await ensureRecapImageTable();\n const client = getDbExec();\n const { rowsAffected } = await client.execute({\n sql: `DELETE FROM recap_images WHERE created_at < ?`,\n args: [now - RECAP_IMAGE_TTL_MS],\n });\n return rowsAffected;\n}\n\n/** Generate a long, unguessable lowercase-hex token (default 32 bytes → 64 hex chars). */\nexport function generateRecapImageToken(byteLength = 32): string {\n return randomBytes(byteLength).toString(\"hex\");\n}\n\n/** True when `token` matches the strict hex token format (no traversal characters). */\nexport function isValidRecapImageToken(\n token: string | undefined | null,\n): boolean {\n return typeof token === \"string\" && TOKEN_PATTERN.test(token);\n}\n\nexport interface StoredRecapImage {\n bytes: Buffer;\n contentType: string;\n}\n\n/**\n * Store PNG bytes and return the freshly minted token. Caller is responsible\n * for enforcing the size cap before calling (we re-check defensively here too).\n */\nexport async function saveRecapImage(\n png: Buffer,\n options: { ownerEmail?: string | null } = {},\n): Promise<{ token: string }> {\n if (png.byteLength > RECAP_IMAGE_MAX_BYTES) {\n throw new Error(\"recap image exceeds maximum size\");\n }\n await ensureRecapImageTable();\n const client = getDbExec();\n const token = generateRecapImageToken();\n await client.execute({\n sql: `INSERT INTO recap_images\n (token, png_base64, content_type, byte_length, owner_email, created_at)\n VALUES (?, ?, ?, ?, ?, ?)`,\n args: [\n token,\n png.toString(\"base64\"),\n RECAP_IMAGE_CONTENT_TYPE,\n png.byteLength,\n options.ownerEmail ?? null,\n Date.now(),\n ],\n });\n // Best-effort retention: expire old images so the table and the set of public\n // image URLs stay bounded. Never let a cleanup failure fail the upload.\n await pruneExpiredRecapImages().catch(() => {});\n return { token };\n}\n\n/**\n * Load a stored image by token. Returns `null` for an unknown or malformed\n * token. Never returns anything but the opaque image bytes — no plan data.\n */\nexport async function getRecapImage(\n token: string,\n): Promise<StoredRecapImage | null> {\n if (!isValidRecapImageToken(token)) return null;\n await ensureRecapImageTable();\n const client = getDbExec();\n const { rows } = await client.execute({\n sql: `SELECT png_base64, content_type FROM recap_images WHERE token = ? LIMIT 1`,\n args: [token],\n });\n const row = rows[0] as\n | { png_base64?: unknown; content_type?: unknown }\n | undefined;\n if (!row || typeof row.png_base64 !== \"string\") return null;\n return {\n bytes: Buffer.from(row.png_base64, \"base64\"),\n // Stored content type is always image/png; never trust it for response\n // headers — the route hard-codes image/png — but surface it for callers.\n contentType:\n typeof row.content_type === \"string\"\n ? row.content_type\n : RECAP_IMAGE_CONTENT_TYPE,\n };\n}\n"]}
|
|
@@ -128,10 +128,12 @@
|
|
|
128
128
|
font-family:
|
|
129
129
|
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
130
130
|
font-size: 0.86em;
|
|
131
|
-
/*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
/* Theme-aware code surface: follows the app's muted/foreground/border tokens
|
|
132
|
+
so block code respects light and dark mode (the token palette below is
|
|
133
|
+
tuned to read on a muted surface in both). */
|
|
134
|
+
background: hsl(var(--muted));
|
|
135
|
+
color: hsl(var(--foreground));
|
|
136
|
+
border: 1px solid hsl(var(--border));
|
|
135
137
|
border-radius: 6px;
|
|
136
138
|
padding: 0.8em 1em;
|
|
137
139
|
margin: 0.55em 0;
|
|
@@ -147,53 +149,59 @@
|
|
|
147
149
|
color: inherit;
|
|
148
150
|
}
|
|
149
151
|
|
|
150
|
-
/* highlight.js (lowlight) token palette
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
/* highlight.js (lowlight) token palette. Scoped to block code so inline `code`
|
|
153
|
+
keeps its own background. No highlight.js base theme is imported; these rules
|
|
154
|
+
are the entire theme, tuned (mid-lightness HSL) to read on the muted surface
|
|
155
|
+
above in both light and dark mode. */
|
|
153
156
|
.an-rich-md-prose pre code .hljs-comment,
|
|
154
157
|
.an-rich-md-prose pre code .hljs-quote {
|
|
155
|
-
color:
|
|
158
|
+
color: hsl(var(--muted-foreground));
|
|
156
159
|
font-style: italic;
|
|
157
160
|
}
|
|
158
161
|
.an-rich-md-prose pre code .hljs-keyword,
|
|
159
162
|
.an-rich-md-prose pre code .hljs-selector-tag,
|
|
160
163
|
.an-rich-md-prose pre code .hljs-literal,
|
|
161
|
-
.an-rich-md-prose pre code .hljs-doctag
|
|
162
|
-
|
|
163
|
-
color: #ff7b72;
|
|
164
|
+
.an-rich-md-prose pre code .hljs-doctag {
|
|
165
|
+
color: hsl(280 60% 58%);
|
|
164
166
|
}
|
|
165
167
|
.an-rich-md-prose pre code .hljs-string,
|
|
166
168
|
.an-rich-md-prose pre code .hljs-regexp,
|
|
167
169
|
.an-rich-md-prose pre code .hljs-addition {
|
|
168
|
-
color:
|
|
170
|
+
color: hsl(140 52% 40%);
|
|
169
171
|
}
|
|
170
172
|
.an-rich-md-prose pre code .hljs-number,
|
|
171
173
|
.an-rich-md-prose pre code .hljs-symbol,
|
|
174
|
+
.an-rich-md-prose pre code .hljs-literal {
|
|
175
|
+
color: hsl(28 78% 48%);
|
|
176
|
+
}
|
|
172
177
|
.an-rich-md-prose pre code .hljs-meta {
|
|
173
|
-
color:
|
|
178
|
+
color: hsl(var(--muted-foreground));
|
|
174
179
|
}
|
|
175
180
|
.an-rich-md-prose pre code .hljs-title,
|
|
176
181
|
.an-rich-md-prose pre code .hljs-title.function_,
|
|
177
182
|
.an-rich-md-prose pre code .hljs-section {
|
|
178
|
-
color:
|
|
183
|
+
color: hsl(210 72% 52%);
|
|
179
184
|
}
|
|
180
185
|
.an-rich-md-prose pre code .hljs-built_in,
|
|
181
186
|
.an-rich-md-prose pre code .hljs-type,
|
|
182
187
|
.an-rich-md-prose pre code .hljs-title.class_,
|
|
183
188
|
.an-rich-md-prose pre code .hljs-variable.language_ {
|
|
184
|
-
color:
|
|
189
|
+
color: hsl(190 64% 40%);
|
|
185
190
|
}
|
|
186
191
|
.an-rich-md-prose pre code .hljs-attr,
|
|
187
192
|
.an-rich-md-prose pre code .hljs-attribute,
|
|
188
193
|
.an-rich-md-prose pre code .hljs-property,
|
|
189
194
|
.an-rich-md-prose pre code .hljs-params {
|
|
190
|
-
color:
|
|
195
|
+
color: hsl(35 68% 46%);
|
|
191
196
|
}
|
|
192
197
|
.an-rich-md-prose pre code .hljs-name,
|
|
193
198
|
.an-rich-md-prose pre code .hljs-tag,
|
|
194
199
|
.an-rich-md-prose pre code .hljs-selector-id,
|
|
195
200
|
.an-rich-md-prose pre code .hljs-selector-class {
|
|
196
|
-
color:
|
|
201
|
+
color: hsl(350 62% 52%);
|
|
202
|
+
}
|
|
203
|
+
.an-rich-md-prose pre code .hljs-deletion {
|
|
204
|
+
color: hsl(0 62% 52%);
|
|
197
205
|
}
|
|
198
206
|
.an-rich-md-prose pre code .hljs-emphasis {
|
|
199
207
|
font-style: italic;
|
|
@@ -202,6 +210,47 @@
|
|
|
202
210
|
font-weight: 600;
|
|
203
211
|
}
|
|
204
212
|
|
|
213
|
+
/* In dark mode, lift the token lightness for contrast on the dark muted surface. */
|
|
214
|
+
.dark .an-rich-md-prose pre code .hljs-keyword,
|
|
215
|
+
.dark .an-rich-md-prose pre code .hljs-selector-tag,
|
|
216
|
+
.dark .an-rich-md-prose pre code .hljs-literal,
|
|
217
|
+
.dark .an-rich-md-prose pre code .hljs-doctag {
|
|
218
|
+
color: hsl(280 72% 72%);
|
|
219
|
+
}
|
|
220
|
+
.dark .an-rich-md-prose pre code .hljs-string,
|
|
221
|
+
.dark .an-rich-md-prose pre code .hljs-regexp,
|
|
222
|
+
.dark .an-rich-md-prose pre code .hljs-addition {
|
|
223
|
+
color: hsl(140 48% 62%);
|
|
224
|
+
}
|
|
225
|
+
.dark .an-rich-md-prose pre code .hljs-number,
|
|
226
|
+
.dark .an-rich-md-prose pre code .hljs-symbol,
|
|
227
|
+
.dark .an-rich-md-prose pre code .hljs-literal {
|
|
228
|
+
color: hsl(30 85% 65%);
|
|
229
|
+
}
|
|
230
|
+
.dark .an-rich-md-prose pre code .hljs-title,
|
|
231
|
+
.dark .an-rich-md-prose pre code .hljs-title.function_,
|
|
232
|
+
.dark .an-rich-md-prose pre code .hljs-section {
|
|
233
|
+
color: hsl(210 80% 70%);
|
|
234
|
+
}
|
|
235
|
+
.dark .an-rich-md-prose pre code .hljs-built_in,
|
|
236
|
+
.dark .an-rich-md-prose pre code .hljs-type,
|
|
237
|
+
.dark .an-rich-md-prose pre code .hljs-title.class_,
|
|
238
|
+
.dark .an-rich-md-prose pre code .hljs-variable.language_ {
|
|
239
|
+
color: hsl(190 70% 62%);
|
|
240
|
+
}
|
|
241
|
+
.dark .an-rich-md-prose pre code .hljs-attr,
|
|
242
|
+
.dark .an-rich-md-prose pre code .hljs-attribute,
|
|
243
|
+
.dark .an-rich-md-prose pre code .hljs-property,
|
|
244
|
+
.dark .an-rich-md-prose pre code .hljs-params {
|
|
245
|
+
color: hsl(40 78% 66%);
|
|
246
|
+
}
|
|
247
|
+
.dark .an-rich-md-prose pre code .hljs-name,
|
|
248
|
+
.dark .an-rich-md-prose pre code .hljs-tag,
|
|
249
|
+
.dark .an-rich-md-prose pre code .hljs-selector-id,
|
|
250
|
+
.dark .an-rich-md-prose pre code .hljs-selector-class {
|
|
251
|
+
color: hsl(350 72% 68%);
|
|
252
|
+
}
|
|
253
|
+
|
|
205
254
|
.an-rich-md-prose hr {
|
|
206
255
|
border: none;
|
|
207
256
|
border-top: 1px solid hsl(var(--border));
|
|
@@ -2,6 +2,11 @@ packages:
|
|
|
2
2
|
- "packages/*"
|
|
3
3
|
- "apps/*"
|
|
4
4
|
|
|
5
|
+
overrides:
|
|
6
|
+
"@assistant-ui/store": ">=0.2.9 <0.2.14"
|
|
7
|
+
"@assistant-ui/tap": "^0.5.14"
|
|
8
|
+
better-auth: "1.6.0"
|
|
9
|
+
|
|
5
10
|
onlyBuiltDependencies:
|
|
6
11
|
- "@swc/core"
|
|
7
12
|
- better-sqlite3
|
|
@@ -10,3 +15,12 @@ onlyBuiltDependencies:
|
|
|
10
15
|
- esbuild
|
|
11
16
|
- lightningcss
|
|
12
17
|
- node-pty
|
|
18
|
+
|
|
19
|
+
allowBuilds:
|
|
20
|
+
"@swc/core": true
|
|
21
|
+
better-sqlite3: true
|
|
22
|
+
canvas: true
|
|
23
|
+
electron: true
|
|
24
|
+
esbuild: true
|
|
25
|
+
lightningcss: true
|
|
26
|
+
node-pty: true
|
|
@@ -68,6 +68,16 @@ The result: Claude-Code-level flexibility for each user, with normal SaaS deploy
|
|
|
68
68
|
|
|
69
69
|
You don't have to. Every template is also available as a hosted app on `agent-native.com` — `mail.agent-native.com`, `calendar.agent-native.com`, and so on. Use the hosted version for free or paid; fork only when you want to change something the hosted version doesn't expose.
|
|
70
70
|
|
|
71
|
+
## Try it with a skill {#try-with-a-skill}
|
|
72
|
+
|
|
73
|
+
Don't want to scaffold a whole app yet? Add agent-native superpowers to a coding agent you already use — Claude Code, Codex, or Cursor — with a single command. Installing the **Plans** skill turns the plans your agent writes into structured, reviewable docs with diagrams, wireframes, and inline comments:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx @agent-native/core@latest skills add visual-plan
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
That one command installs the skill instructions, registers the hosted MCP connector, and signs you in — no marketplace browsing, no manual OAuth. Then run `/visual-plan` in your agent. See the [Skills Guide](/docs/skills-guide#app-backed-skills) for more skills, local/offline installs, and how app-backed skills work.
|
|
80
|
+
|
|
71
81
|
## Building on this
|
|
72
82
|
|
|
73
83
|
- [**Getting Started**](/docs/getting-started) — clone your first template and run it locally
|