stagent 0.6.0 → 0.6.2
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/dist/cli.js +1 -1
- package/package.json +4 -4
- package/src/app/api/channels/[id]/route.ts +3 -2
- package/src/app/api/channels/inbound/slack/route.ts +9 -2
- package/src/app/api/channels/inbound/telegram/poll/route.ts +12 -0
- package/src/app/api/channels/inbound/telegram/route.ts +12 -1
- package/src/app/api/channels/route.ts +3 -2
- package/src/app/api/data/clear/route.ts +4 -0
- package/src/app/api/data/seed/route.ts +4 -0
- package/src/app/api/documents/route.ts +36 -6
- package/src/app/api/tasks/[id]/respond/route.ts +23 -1
- package/src/app/layout.tsx +9 -0
- package/src/lib/__tests__/npx-process-cwd.test.ts +138 -0
- package/src/lib/agents/claude-agent.ts +1 -1
- package/src/lib/agents/profiles/registry.ts +3 -8
- package/src/lib/book/chapter-generator.ts +6 -3
- package/src/lib/book/content.ts +3 -1
- package/src/lib/book/update-detector.ts +3 -1
- package/src/lib/channels/types.ts +32 -0
- package/src/lib/data/seed-data/documents.ts +18 -24
- package/src/lib/db/schema.ts +4 -1
- package/src/lib/docs/reader.ts +4 -2
- package/src/lib/documents/processors/spreadsheet.ts +11 -7
- package/src/app/apple-icon.tsx +0 -31
- package/src/app/icon.tsx +0 -30
package/dist/cli.js
CHANGED
|
@@ -755,7 +755,7 @@ async function main() {
|
|
|
755
755
|
const candidate = join3(searchDir, "node_modules", "next", "package.json");
|
|
756
756
|
if (existsSync2(candidate)) {
|
|
757
757
|
const hoistedRoot = searchDir;
|
|
758
|
-
for (const name of ["src", "public", "docs"]) {
|
|
758
|
+
for (const name of ["src", "public", "docs", "book", "ai-native-notes"]) {
|
|
759
759
|
const dest = join3(hoistedRoot, name);
|
|
760
760
|
const src = join3(appDir, name);
|
|
761
761
|
if (!existsSync2(dest) && existsSync2(src)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stagent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "AI Business Operating System — run your business with AI agents. Local-first, multi-provider, governed.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"@tailwindcss/postcss": "^4",
|
|
72
72
|
"@tailwindcss/typography": "^0.5",
|
|
73
73
|
"@tanstack/react-table": "^8.21.3",
|
|
74
|
+
"@types/node": "^22",
|
|
74
75
|
"better-sqlite3": "^12",
|
|
75
76
|
"class-variance-authority": "^0.7.1",
|
|
76
77
|
"clsx": "^2",
|
|
@@ -78,6 +79,7 @@
|
|
|
78
79
|
"commander": "^14",
|
|
79
80
|
"cron-parser": "^5.5.0",
|
|
80
81
|
"drizzle-orm": "^0.45",
|
|
82
|
+
"exceljs": "^4.4.0",
|
|
81
83
|
"image-size": "^2.0.2",
|
|
82
84
|
"js-yaml": "^4.1.1",
|
|
83
85
|
"jszip": "^3.10.1",
|
|
@@ -95,14 +97,13 @@
|
|
|
95
97
|
"react-markdown": "^10.1.0",
|
|
96
98
|
"remark-gfm": "^4.0.1",
|
|
97
99
|
"sharp": "^0.34.5",
|
|
98
|
-
"smol-toml": "^1.6.
|
|
100
|
+
"smol-toml": "^1.6.1",
|
|
99
101
|
"sonner": "^2.0.7",
|
|
100
102
|
"sugar-high": "^1.0.0",
|
|
101
103
|
"tailwind-merge": "^3",
|
|
102
104
|
"tailwindcss": "^4",
|
|
103
105
|
"tw-animate-css": "^1",
|
|
104
106
|
"typescript": "^5",
|
|
105
|
-
"xlsx": "^0.18.5",
|
|
106
107
|
"zod": "^4.3.6"
|
|
107
108
|
},
|
|
108
109
|
"devDependencies": {
|
|
@@ -111,7 +112,6 @@
|
|
|
111
112
|
"@testing-library/react": "^16.3.2",
|
|
112
113
|
"@types/better-sqlite3": "^7",
|
|
113
114
|
"@types/js-yaml": "^4.0.9",
|
|
114
|
-
"@types/node": "^22",
|
|
115
115
|
"@types/react": "^19",
|
|
116
116
|
"@types/react-dom": "^19",
|
|
117
117
|
"@types/sharp": "^0.31.1",
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { channelConfigs } from "@/lib/db/schema";
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
|
+
import { maskChannelRow } from "@/lib/channels/types";
|
|
5
6
|
|
|
6
7
|
export async function GET(
|
|
7
8
|
_req: NextRequest,
|
|
@@ -18,7 +19,7 @@ export async function GET(
|
|
|
18
19
|
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
return NextResponse.json(channel);
|
|
22
|
+
return NextResponse.json(maskChannelRow(channel));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export async function PATCH(
|
|
@@ -79,7 +80,7 @@ export async function PATCH(
|
|
|
79
80
|
.from(channelConfigs)
|
|
80
81
|
.where(eq(channelConfigs.id, id));
|
|
81
82
|
|
|
82
|
-
return NextResponse.json(updated);
|
|
83
|
+
return NextResponse.json(maskChannelRow(updated));
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export async function DELETE(
|
|
@@ -56,8 +56,15 @@ export async function POST(req: NextRequest) {
|
|
|
56
56
|
return NextResponse.json({ error: "Invalid channel config" }, { status: 500 });
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
if (parsedConfig.signingSecret
|
|
59
|
+
// Require signingSecret — refuse to process inbound messages without signature verification
|
|
60
|
+
if (!parsedConfig.signingSecret) {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ error: "Channel config missing signingSecret — cannot verify request" },
|
|
63
|
+
{ status: 401 }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (slackAdapter.verifySignature) {
|
|
61
68
|
const headers: Record<string, string> = {};
|
|
62
69
|
req.headers.forEach((value, key) => {
|
|
63
70
|
headers[key] = value;
|
|
@@ -15,6 +15,13 @@ import { handleInboundMessage } from "@/lib/channels/gateway";
|
|
|
15
15
|
* Returns the number of updates processed.
|
|
16
16
|
*/
|
|
17
17
|
export async function POST(req: NextRequest) {
|
|
18
|
+
// Guard: only accept requests from the internal poller.
|
|
19
|
+
// The internal scheduler sets this header; external callers won't have it.
|
|
20
|
+
const internalToken = req.headers.get("x-stagent-internal");
|
|
21
|
+
if (internalToken !== "poll") {
|
|
22
|
+
return NextResponse.json({ error: "Unauthorized — internal use only" }, { status: 401 });
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
const configId = req.nextUrl.searchParams.get("configId");
|
|
19
26
|
if (!configId) {
|
|
20
27
|
return NextResponse.json({ error: "Missing configId" }, { status: 400 });
|
|
@@ -30,6 +37,11 @@ export async function POST(req: NextRequest) {
|
|
|
30
37
|
return NextResponse.json({ error: "Channel not found" }, { status: 404 });
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
// Refuse to poll disabled channels
|
|
41
|
+
if (config.status !== "active") {
|
|
42
|
+
return NextResponse.json({ error: "Channel is not active" }, { status: 403 });
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
let parsedConfig: Record<string, unknown>;
|
|
34
46
|
try {
|
|
35
47
|
parsedConfig = JSON.parse(config.config) as Record<string, unknown>;
|
|
@@ -39,8 +39,19 @@ export async function POST(req: NextRequest) {
|
|
|
39
39
|
return NextResponse.json({ error: "Invalid channel config" }, { status: 500 });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Require webhookSecret — refuse to process inbound messages without authentication
|
|
42
43
|
const expectedSecret = parsedConfig.webhookSecret as string | undefined;
|
|
43
|
-
if (expectedSecret
|
|
44
|
+
if (!expectedSecret) {
|
|
45
|
+
return NextResponse.json(
|
|
46
|
+
{ error: "Channel config missing webhookSecret — cannot verify request" },
|
|
47
|
+
{ status: 401 }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// NOTE: The secret is passed as a query-string parameter. This is Telegram's recommended
|
|
52
|
+
// webhook verification design (https://core.telegram.org/bots/api#setwebhook secret_token).
|
|
53
|
+
// Query strings may appear in server access logs — ensure logs are access-controlled.
|
|
54
|
+
if (secret !== expectedSecret) {
|
|
44
55
|
return NextResponse.json({ error: "Invalid secret" }, { status: 403 });
|
|
45
56
|
}
|
|
46
57
|
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { channelConfigs } from "@/lib/db/schema";
|
|
4
4
|
import { desc, eq } from "drizzle-orm";
|
|
5
|
+
import { maskChannelRow } from "@/lib/channels/types";
|
|
5
6
|
|
|
6
7
|
const VALID_CHANNEL_TYPES = ["slack", "telegram", "webhook"] as const;
|
|
7
8
|
|
|
@@ -11,7 +12,7 @@ export async function GET() {
|
|
|
11
12
|
.from(channelConfigs)
|
|
12
13
|
.orderBy(desc(channelConfigs.createdAt));
|
|
13
14
|
|
|
14
|
-
return NextResponse.json(result);
|
|
15
|
+
return NextResponse.json(result.map(maskChannelRow));
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export async function POST(req: NextRequest) {
|
|
@@ -67,5 +68,5 @@ export async function POST(req: NextRequest) {
|
|
|
67
68
|
.from(channelConfigs)
|
|
68
69
|
.where(eq(channelConfigs.id, id));
|
|
69
70
|
|
|
70
|
-
return NextResponse.json(created, { status: 201 });
|
|
71
|
+
return NextResponse.json(maskChannelRow(created), { status: 201 });
|
|
71
72
|
}
|
|
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { clearAllData } from "@/lib/data/clear";
|
|
3
3
|
|
|
4
4
|
export async function POST() {
|
|
5
|
+
if (process.env.NODE_ENV === "production") {
|
|
6
|
+
return NextResponse.json(null, { status: 404 });
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
try {
|
|
6
10
|
const deleted = clearAllData();
|
|
7
11
|
return NextResponse.json({ success: true, deleted });
|
|
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { seedSampleData } from "@/lib/data/seed";
|
|
3
3
|
|
|
4
4
|
export async function POST() {
|
|
5
|
+
if (process.env.NODE_ENV === "production") {
|
|
6
|
+
return NextResponse.json(null, { status: 404 });
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
try {
|
|
6
10
|
const seeded = await seedSampleData();
|
|
7
11
|
return NextResponse.json({ success: true, seeded });
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { db } from "@/lib/db";
|
|
3
3
|
import { documents, tasks, projects } from "@/lib/db/schema";
|
|
4
|
-
import { eq, and, like, or, desc
|
|
4
|
+
import { eq, and, like, or, desc } from "drizzle-orm";
|
|
5
5
|
import { access, stat, copyFile, mkdir } from "fs/promises";
|
|
6
|
-
import { basename, extname, join } from "path";
|
|
6
|
+
import path, { basename, extname, join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
7
8
|
import crypto from "crypto";
|
|
8
9
|
import { getStagentUploadsDir } from "@/lib/utils/stagent-paths";
|
|
9
10
|
import { processDocument } from "@/lib/documents/processor";
|
|
@@ -120,18 +121,47 @@ export async function POST(req: NextRequest) {
|
|
|
120
121
|
|
|
121
122
|
const body = parsed.data;
|
|
122
123
|
|
|
124
|
+
// Path traversal protection: resolve and validate the file path
|
|
125
|
+
const resolvedPath = path.resolve(body.file_path);
|
|
126
|
+
const home = homedir();
|
|
127
|
+
const SENSITIVE_PREFIXES = ["/etc", "/var", "/proc", "/sys", "/dev", "/root"];
|
|
128
|
+
const SENSITIVE_HOME_DIRS = [".ssh", ".gnupg", ".aws", ".config", ".env"];
|
|
129
|
+
|
|
130
|
+
if (SENSITIVE_PREFIXES.some((prefix) => resolvedPath.startsWith(prefix))) {
|
|
131
|
+
return NextResponse.json(
|
|
132
|
+
{ error: "Access denied: path points to a restricted system directory" },
|
|
133
|
+
{ status: 403 }
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (resolvedPath.startsWith(home)) {
|
|
138
|
+
const relativeToHome = resolvedPath.slice(home.length + 1);
|
|
139
|
+
if (SENSITIVE_HOME_DIRS.some((dir) => relativeToHome.startsWith(dir))) {
|
|
140
|
+
return NextResponse.json(
|
|
141
|
+
{ error: "Access denied: path points to a sensitive home directory" },
|
|
142
|
+
{ status: 403 }
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} else if (!resolvedPath.startsWith("/tmp")) {
|
|
146
|
+
// Outside home and not /tmp — reject
|
|
147
|
+
return NextResponse.json(
|
|
148
|
+
{ error: "Access denied: path must be under the user's home directory or /tmp" },
|
|
149
|
+
{ status: 403 }
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
123
153
|
try {
|
|
124
|
-
await access(
|
|
154
|
+
await access(resolvedPath);
|
|
125
155
|
} catch {
|
|
126
156
|
return NextResponse.json({ error: `File not found: ${body.file_path}` }, { status: 400 });
|
|
127
157
|
}
|
|
128
158
|
|
|
129
|
-
const stats = await stat(
|
|
159
|
+
const stats = await stat(resolvedPath);
|
|
130
160
|
if (!stats.isFile()) {
|
|
131
161
|
return NextResponse.json({ error: `Not a file: ${body.file_path}` }, { status: 400 });
|
|
132
162
|
}
|
|
133
163
|
|
|
134
|
-
const originalName = basename(
|
|
164
|
+
const originalName = basename(resolvedPath);
|
|
135
165
|
const ext = extname(originalName).toLowerCase();
|
|
136
166
|
const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
137
167
|
const id = crypto.randomUUID();
|
|
@@ -140,7 +170,7 @@ export async function POST(req: NextRequest) {
|
|
|
140
170
|
const uploadsDir = getStagentUploadsDir();
|
|
141
171
|
await mkdir(uploadsDir, { recursive: true });
|
|
142
172
|
const storagePath = join(uploadsDir, filename);
|
|
143
|
-
await copyFile(
|
|
173
|
+
await copyFile(resolvedPath, storagePath);
|
|
144
174
|
|
|
145
175
|
const now = new Date();
|
|
146
176
|
await db.insert(documents).values({
|
|
@@ -43,8 +43,30 @@ export async function POST(
|
|
|
43
43
|
return NextResponse.json({ error: "Already responded" }, { status: 409 });
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Validate updatedInput keys against the original tool input to prevent injection
|
|
47
|
+
let sanitizedUpdatedInput = updatedInput;
|
|
48
|
+
if (updatedInput !== undefined && updatedInput !== null && typeof updatedInput === "object" && !Array.isArray(updatedInput)) {
|
|
49
|
+
try {
|
|
50
|
+
const originalToolInput = typeof notification.toolInput === "string" ? JSON.parse(notification.toolInput) : (notification.toolInput ?? {});
|
|
51
|
+
if (typeof originalToolInput === "object" && originalToolInput !== null) {
|
|
52
|
+
const allowedKeys = new Set(Object.keys(originalToolInput));
|
|
53
|
+
const inputRecord = updatedInput as Record<string, unknown>;
|
|
54
|
+
const extraKeys = Object.keys(inputRecord).filter((k) => !allowedKeys.has(k));
|
|
55
|
+
if (extraKeys.length > 0) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ error: `updatedInput contains disallowed keys: ${extraKeys.join(", ")}` },
|
|
58
|
+
{ status: 400 }
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// If we can't parse the original notification data, reject updatedInput entirely
|
|
64
|
+
sanitizedUpdatedInput = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
// Write response — the polling loop in claude-agent.ts will detect this
|
|
47
|
-
const responseData = { behavior, message, updatedInput, alwaysAllow };
|
|
69
|
+
const responseData = { behavior, message, updatedInput: sanitizedUpdatedInput, alwaysAllow };
|
|
48
70
|
await db
|
|
49
71
|
.update(notifications)
|
|
50
72
|
.set({
|
package/src/app/layout.tsx
CHANGED
|
@@ -22,6 +22,15 @@ const jetbrainsMono = JetBrains_Mono({
|
|
|
22
22
|
export const metadata: Metadata = {
|
|
23
23
|
title: "Stagent",
|
|
24
24
|
description: "AI agent task management",
|
|
25
|
+
icons: {
|
|
26
|
+
icon: [
|
|
27
|
+
{ url: "/stagent-s-64.png", sizes: "64x64", type: "image/png" },
|
|
28
|
+
{ url: "/icon.svg", type: "image/svg+xml" },
|
|
29
|
+
],
|
|
30
|
+
apple: [
|
|
31
|
+
{ url: "/stagent-s-128.png", sizes: "128x128", type: "image/png" },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
25
34
|
};
|
|
26
35
|
|
|
27
36
|
// Inline theme bootstrap prevents a flash between the server render and local theme preference.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Safety-net test: server-side code must NOT use process.cwd() for resolving
|
|
7
|
+
* app-internal assets (docs, book, public, src). Under npx distribution,
|
|
8
|
+
* process.cwd() returns the npm cache directory, not the app root.
|
|
9
|
+
*
|
|
10
|
+
* Allowed alternatives:
|
|
11
|
+
* - import.meta.dirname / __dirname (resolves relative to source file)
|
|
12
|
+
* - getLaunchCwd() (resolves to user's working directory)
|
|
13
|
+
* - Static file conventions (e.g., src/app/icon.png)
|
|
14
|
+
*
|
|
15
|
+
* Excluded:
|
|
16
|
+
* - bin/cli.ts (CLI entrypoint — it defines the cwd context)
|
|
17
|
+
* - Test files (__tests__/)
|
|
18
|
+
* - workspace-context.ts (defines getLaunchCwd itself, fallback is intentional)
|
|
19
|
+
*/
|
|
20
|
+
describe("npx safety: no process.cwd() for app-internal asset resolution", () => {
|
|
21
|
+
// Project root (src/lib/__tests__/ → 3 levels up)
|
|
22
|
+
const PROJECT_ROOT = resolve(__dirname, "..", "..", "..");
|
|
23
|
+
|
|
24
|
+
/** Recursively collect .ts/.tsx files, skipping node_modules and __tests__ */
|
|
25
|
+
function collectFiles(dir: string): string[] {
|
|
26
|
+
const results: string[] = [];
|
|
27
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
28
|
+
const full = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (entry.name === "node_modules" || entry.name === "__tests__" || entry.name === ".next") continue;
|
|
31
|
+
results.push(...collectFiles(full));
|
|
32
|
+
} else if (/\.(ts|tsx)$/.test(entry.name)) {
|
|
33
|
+
results.push(full);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Files that are intentionally allowed to use process.cwd()
|
|
40
|
+
const ALLOWED_FILES = [
|
|
41
|
+
"bin/cli.ts", // CLI entrypoint defines cwd context
|
|
42
|
+
"src/lib/environment/workspace-context.ts", // defines getLaunchCwd fallback
|
|
43
|
+
"drizzle.config.ts", // build-time config
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Patterns that indicate process.cwd() is used to resolve app-internal paths.
|
|
48
|
+
* We look for join/resolve calls that combine process.cwd() with known app dirs.
|
|
49
|
+
*/
|
|
50
|
+
const DANGEROUS_PATTERNS = [
|
|
51
|
+
/process\.cwd\(\)\s*,\s*["'](?:public|docs|book|ai-native-notes|src)\b/,
|
|
52
|
+
/process\.cwd\(\)\s*,\s*["'].*?\.(?:png|ico|svg|jpg|md|json)["']/,
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
it("server-side code does not use process.cwd() for internal asset paths", () => {
|
|
56
|
+
const srcFiles = collectFiles(join(PROJECT_ROOT, "src"));
|
|
57
|
+
const binFiles = collectFiles(join(PROJECT_ROOT, "bin"));
|
|
58
|
+
const allFiles = [...srcFiles, ...binFiles];
|
|
59
|
+
|
|
60
|
+
const violations: Array<{ file: string; line: number; text: string }> = [];
|
|
61
|
+
|
|
62
|
+
for (const filePath of allFiles) {
|
|
63
|
+
const relative = filePath.replace(PROJECT_ROOT + "/", "");
|
|
64
|
+
|
|
65
|
+
// Skip allowed files
|
|
66
|
+
if (ALLOWED_FILES.some((allowed) => relative.endsWith(allowed))) continue;
|
|
67
|
+
// Skip test files
|
|
68
|
+
if (relative.includes("__tests__")) continue;
|
|
69
|
+
|
|
70
|
+
const content = readFileSync(filePath, "utf-8");
|
|
71
|
+
const lines = content.split("\n");
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i];
|
|
75
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
76
|
+
if (pattern.test(line)) {
|
|
77
|
+
violations.push({ file: relative, line: i + 1, text: line.trim() });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
expect(
|
|
84
|
+
violations,
|
|
85
|
+
`Found process.cwd() used for app-internal paths (breaks under npx):\n${violations
|
|
86
|
+
.map((v) => ` ${v.file}:${v.line} → ${v.text}`)
|
|
87
|
+
.join("\n")}`
|
|
88
|
+
).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("dynamic icon/apple-icon files do not exist (break under npx)", () => {
|
|
92
|
+
const appDir = join(PROJECT_ROOT, "src", "app");
|
|
93
|
+
const dynamicIcons = ["icon.tsx", "icon.jsx", "apple-icon.tsx", "apple-icon.jsx"];
|
|
94
|
+
|
|
95
|
+
const found = dynamicIcons.filter((name) => {
|
|
96
|
+
try {
|
|
97
|
+
statSync(join(appDir, name));
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(
|
|
105
|
+
found,
|
|
106
|
+
`Dynamic icon files found — these break under npx because they use process.cwd(). Use explicit metadata.icons in layout.tsx instead: ${found.join(", ")}`
|
|
107
|
+
).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("layout.tsx has explicit icons metadata (not convention-based)", () => {
|
|
111
|
+
const layoutPath = join(PROJECT_ROOT, "src", "app", "layout.tsx");
|
|
112
|
+
const content = readFileSync(layoutPath, "utf-8");
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
content.includes("icons:"),
|
|
116
|
+
"layout.tsx must have explicit icons metadata — convention-based icon.png/icon.tsx files break under npx"
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("icon assets referenced in metadata exist in public/", () => {
|
|
121
|
+
const publicDir = join(PROJECT_ROOT, "public");
|
|
122
|
+
const requiredIcons = ["stagent-s-64.png", "stagent-s-128.png", "icon.svg"];
|
|
123
|
+
|
|
124
|
+
const missing = requiredIcons.filter((name) => {
|
|
125
|
+
try {
|
|
126
|
+
statSync(join(publicDir, name));
|
|
127
|
+
return false;
|
|
128
|
+
} catch {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(
|
|
134
|
+
missing,
|
|
135
|
+
`Missing icon assets in public/: ${missing.join(", ")}`
|
|
136
|
+
).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -372,7 +372,7 @@ export async function buildTaskQueryContext(
|
|
|
372
372
|
const outputInstructions = buildTaskOutputInstructions(task.id);
|
|
373
373
|
const learnedCtx = getActiveLearnedContext(profileId);
|
|
374
374
|
const learnedCtxBlock = learnedCtx
|
|
375
|
-
? `## Learned Context\
|
|
375
|
+
? `## Learned Context\n<learned-context>\n${learnedCtx}\n</learned-context>`
|
|
376
376
|
: "";
|
|
377
377
|
|
|
378
378
|
// Resolve working directory: project's workingDirectory > launch cwd
|
|
@@ -15,20 +15,15 @@ import { eq, and } from "drizzle-orm";
|
|
|
15
15
|
* Builtins ship inside the repo at src/lib/agents/profiles/builtins/.
|
|
16
16
|
* At runtime they are copied (if missing) to ~/.claude/skills/ so users
|
|
17
17
|
* can customize them without touching source.
|
|
18
|
+
* Uses import.meta.dirname (not process.cwd()) so it works under npx.
|
|
18
19
|
*/
|
|
19
|
-
const
|
|
20
|
+
const BUILTINS_DIR = path.resolve(
|
|
20
21
|
import.meta.dirname ?? __dirname,
|
|
21
22
|
"builtins"
|
|
22
23
|
);
|
|
23
|
-
const BUILTINS_DIR_FALLBACK = path.join(
|
|
24
|
-
process.cwd(),
|
|
25
|
-
"src/lib/agents/profiles/builtins"
|
|
26
|
-
);
|
|
27
24
|
|
|
28
|
-
/** Resolve builtins dir — import.meta.dirname may point to .next/ in bundled contexts */
|
|
29
25
|
function getBuiltinsDir(): string {
|
|
30
|
-
|
|
31
|
-
return BUILTINS_DIR_FALLBACK;
|
|
26
|
+
return BUILTINS_DIR;
|
|
32
27
|
}
|
|
33
28
|
|
|
34
29
|
const SKILLS_DIR = path.join(
|
|
@@ -31,8 +31,11 @@ export function gatherChapterContext(chapterId: string): ChapterContext {
|
|
|
31
31
|
const sourceDocSlugs = mapping?.docs ?? [];
|
|
32
32
|
const slug = chapterIdToSlug(chapterId);
|
|
33
33
|
|
|
34
|
+
// Resolve paths relative to source file, not cwd (npx-safe)
|
|
35
|
+
const appRoot = join(import.meta.dirname ?? __dirname, "..", "..", "..");
|
|
36
|
+
|
|
34
37
|
// Read the current chapter markdown (if it exists)
|
|
35
|
-
const chapterMdPath = join(
|
|
38
|
+
const chapterMdPath = join(appRoot, "book", "chapters", `${slug}.md`);
|
|
36
39
|
const currentMarkdown = existsSync(chapterMdPath)
|
|
37
40
|
? readFileSync(chapterMdPath, "utf-8")
|
|
38
41
|
: null;
|
|
@@ -40,7 +43,7 @@ export function gatherChapterContext(chapterId: string): ChapterContext {
|
|
|
40
43
|
// Read related playbook docs for content
|
|
41
44
|
const sourceContents: string[] = [];
|
|
42
45
|
for (const docSlug of sourceDocSlugs) {
|
|
43
|
-
const docPath = join(
|
|
46
|
+
const docPath = join(appRoot, "docs", "features", `${docSlug}.md`);
|
|
44
47
|
if (existsSync(docPath)) {
|
|
45
48
|
const content = readFileSync(docPath, "utf-8");
|
|
46
49
|
sourceContents.push(`### Feature: ${docSlug}\n${content}`);
|
|
@@ -48,7 +51,7 @@ export function gatherChapterContext(chapterId: string): ChapterContext {
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
// Read the book strategy document
|
|
51
|
-
const strategyPath = join(
|
|
54
|
+
const strategyPath = join(appRoot, "ai-native-notes", "ai-native-book-strategy.md");
|
|
52
55
|
const strategy = existsSync(strategyPath)
|
|
53
56
|
? readFileSync(strategyPath, "utf-8")
|
|
54
57
|
: null;
|
package/src/lib/book/content.ts
CHANGED
|
@@ -154,7 +154,9 @@ function tryLoadMarkdownChapter(id: string): BookChapter | null {
|
|
|
154
154
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
155
155
|
const { parseMarkdownChapter } = require("./markdown-parser") as { parseMarkdownChapter: (md: string, slug: string) => { sections: Array<{ id: string; title: string; content: import("./types").ContentBlock[] }> } };
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
// Resolve relative to source file, not cwd (npx-safe)
|
|
158
|
+
const appRoot = join(import.meta.dirname ?? __dirname, "..", "..", "..");
|
|
159
|
+
const filePath = join(appRoot, "book", "chapters", `${fileSlug}.md`);
|
|
158
160
|
if (!existsSync(filePath)) return null;
|
|
159
161
|
|
|
160
162
|
const content = readFileSync(filePath, "utf-8");
|
|
@@ -33,7 +33,9 @@ function getLastGenerated(chapterId: string): string | null {
|
|
|
33
33
|
const slug = CHAPTER_SLUGS[chapterId];
|
|
34
34
|
if (!slug) return null;
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// Resolve relative to source file, not cwd (npx-safe)
|
|
37
|
+
const appRoot = join(import.meta.dirname ?? __dirname, "..", "..", "..");
|
|
38
|
+
const mdPath = join(appRoot, "book", "chapters", `${slug}.md`);
|
|
37
39
|
if (!existsSync(mdPath)) return null;
|
|
38
40
|
|
|
39
41
|
const content = readFileSync(mdPath, "utf-8");
|
|
@@ -30,6 +30,38 @@ export interface ChannelDeliveryResult {
|
|
|
30
30
|
error?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// ── Credential masking ───────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Fields in channel config JSON that contain secrets and must be masked in API responses. */
|
|
36
|
+
const SENSITIVE_CONFIG_KEYS = ["botToken", "signingSecret", "webhookSecret"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mask sensitive fields in a channel config JSON string.
|
|
40
|
+
* Returns a new JSON string with secrets replaced by "****<last4>".
|
|
41
|
+
*/
|
|
42
|
+
export function maskChannelConfig(configJson: string): string {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(configJson) as Record<string, unknown>;
|
|
45
|
+
for (const key of SENSITIVE_CONFIG_KEYS) {
|
|
46
|
+
const val = parsed[key];
|
|
47
|
+
if (typeof val === "string" && val.length > 0) {
|
|
48
|
+
const last4 = val.slice(-4);
|
|
49
|
+
parsed[key] = `****${last4}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(parsed);
|
|
53
|
+
} catch {
|
|
54
|
+
return configJson;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mask sensitive fields in a channel config row before returning from API.
|
|
60
|
+
*/
|
|
61
|
+
export function maskChannelRow<T extends { config: string }>(row: T): T {
|
|
62
|
+
return { ...row, config: maskChannelConfig(row.config) };
|
|
63
|
+
}
|
|
64
|
+
|
|
33
65
|
/** Normalized inbound message from any channel. */
|
|
34
66
|
export interface InboundMessage {
|
|
35
67
|
text: string;
|
|
@@ -162,15 +162,17 @@ async function createPptx(slides: string[]): Promise<Buffer> {
|
|
|
162
162
|
return Buffer.from(buf);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
/** Create a valid XLSX using
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
const
|
|
165
|
+
/** Create a valid XLSX using exceljs */
|
|
166
|
+
async function createXlsx(csvContent: string): Promise<Buffer> {
|
|
167
|
+
const ExcelJS = await import("exceljs");
|
|
168
|
+
const workbook = new ExcelJS.Workbook();
|
|
169
|
+
const ws = workbook.addWorksheet("Sheet1");
|
|
169
170
|
const rows = csvContent.split("\n").map((line: string) => line.split(","));
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
ws.addRow(row);
|
|
173
|
+
}
|
|
174
|
+
const arrayBuffer = await workbook.xlsx.writeBuffer();
|
|
175
|
+
return Buffer.from(arrayBuffer);
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
/** Create a minimal valid PDF with text content */
|
|
@@ -254,9 +256,8 @@ const DOCUMENTS: DocumentDef[] = [
|
|
|
254
256
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
255
257
|
projectIndex: 0,
|
|
256
258
|
taskIndex: 0,
|
|
257
|
-
content: () =>
|
|
258
|
-
|
|
259
|
-
createXlsxSync(
|
|
259
|
+
content: async () =>
|
|
260
|
+
createXlsx(
|
|
260
261
|
`Ticker,Shares,Avg Cost,Current Price,Market Value,Sector
|
|
261
262
|
NVDA,45,285.50,890.25,40061.25,Technology
|
|
262
263
|
AAPL,120,142.30,178.50,21420.00,Technology
|
|
@@ -273,7 +274,6 @@ LLY,15,580.00,782.50,11737.50,Healthcare
|
|
|
273
274
|
HD,35,325.00,348.90,12211.50,Consumer
|
|
274
275
|
MA,40,368.50,462.80,18512.00,Finance
|
|
275
276
|
CVX,55,152.30,158.40,8712.00,Energy`
|
|
276
|
-
)
|
|
277
277
|
),
|
|
278
278
|
},
|
|
279
279
|
{
|
|
@@ -398,9 +398,8 @@ Tertiary: Newsletter subscription`
|
|
|
398
398
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
399
399
|
projectIndex: 2,
|
|
400
400
|
taskIndex: 10,
|
|
401
|
-
content: () =>
|
|
402
|
-
|
|
403
|
-
createXlsxSync(
|
|
401
|
+
content: async () =>
|
|
402
|
+
createXlsx(
|
|
404
403
|
`Name,Title,Company,Industry,Size,LinkedIn URL,Email,Status
|
|
405
404
|
Sarah Chen,VP Engineering,Acme Corp,SaaS,500-1000,linkedin.com/in/sarachen,s.chen@acmecorp.com,Qualified
|
|
406
405
|
Marcus Johnson,Director of Engineering,Acme Corp,SaaS,500-1000,linkedin.com/in/marcusjohnson,m.johnson@acmecorp.com,Qualified
|
|
@@ -414,7 +413,6 @@ Nina Patel,CTO,QuickShip,Logistics,200-500,linkedin.com/in/ninapatel,n.patel@qui
|
|
|
414
413
|
Alex Turner,Director of Engineering,BrightPath,EdTech,100-200,linkedin.com/in/alexturner,a.turner@brightpath.edu,Qualified
|
|
415
414
|
Sophie Reed,VP Product,GreenGrid,CleanTech,50-200,linkedin.com/in/sophiereed,s.reed@greengrid.io,Researching
|
|
416
415
|
Chris Wong,VP Engineering,NexaPay,FinTech,200-500,linkedin.com/in/chriswong,c.wong@nexapay.com,Qualified`
|
|
417
|
-
)
|
|
418
416
|
),
|
|
419
417
|
},
|
|
420
418
|
{
|
|
@@ -514,9 +512,8 @@ Total: $2,052 (Pre-approved #EXP-2025-0342)`
|
|
|
514
512
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
515
513
|
projectIndex: 3,
|
|
516
514
|
taskIndex: 19,
|
|
517
|
-
content: () =>
|
|
518
|
-
|
|
519
|
-
createXlsxSync(
|
|
515
|
+
content: async () =>
|
|
516
|
+
createXlsx(
|
|
520
517
|
`Date,Category,Vendor,Amount,Description,Receipt
|
|
521
518
|
2025-03-15,Airfare,United Airlines,342.00,SFO to JFK Economy Plus UA 456,receipt-001.pdf
|
|
522
519
|
2025-03-15,Ground Transport,Uber,62.50,JFK to Manhattan Club hotel,receipt-002.pdf
|
|
@@ -536,7 +533,6 @@ Total: $2,052 (Pre-approved #EXP-2025-0342)`
|
|
|
536
533
|
2025-03-15,Meals,Starbucks JFK,8.40,Coffee at terminal,receipt-016.pdf
|
|
537
534
|
2025-03-16,Miscellaneous,CVS Pharmacy,12.80,Phone charger,receipt-017.pdf
|
|
538
535
|
2025-03-17,Miscellaneous,Hotel Concierge,15.00,Luggage storage tip,receipt-018.pdf`
|
|
539
|
-
)
|
|
540
536
|
),
|
|
541
537
|
},
|
|
542
538
|
|
|
@@ -590,9 +586,8 @@ STATUS: 7 of 8 documents collected (87.5%)`
|
|
|
590
586
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
591
587
|
projectIndex: 4,
|
|
592
588
|
taskIndex: 21,
|
|
593
|
-
content: () =>
|
|
594
|
-
|
|
595
|
-
createXlsxSync(
|
|
589
|
+
content: async () =>
|
|
590
|
+
createXlsx(
|
|
596
591
|
`Date,Category,Description,Amount,Deductible,Notes
|
|
597
592
|
2025-01-05,Home Office,Internet service (pro-rata),45.00,Yes,8.3% of total
|
|
598
593
|
2025-01-05,Home Office,Electricity (pro-rata),32.00,Yes,8.3% of total
|
|
@@ -619,7 +614,6 @@ STATUS: 7 of 8 documents collected (87.5%)`
|
|
|
619
614
|
2025-04-01,Health,HSA contribution,500.00,Yes,Monthly
|
|
620
615
|
2025-04-05,Home Office,Internet (pro-rata),45.00,Yes,Monthly
|
|
621
616
|
2025-04-05,Home Office,Electricity (pro-rata),38.00,Yes,Spring cycle`
|
|
622
|
-
)
|
|
623
617
|
),
|
|
624
618
|
},
|
|
625
619
|
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -604,7 +604,10 @@ export const channelConfigs = sqliteTable(
|
|
|
604
604
|
id: text("id").primaryKey(),
|
|
605
605
|
channelType: text("channel_type", { enum: ["slack", "telegram", "webhook"] }).notNull(),
|
|
606
606
|
name: text("name").notNull(),
|
|
607
|
-
|
|
607
|
+
// SECURITY: The config JSON contains credentials (botToken, signingSecret, webhookSecret)
|
|
608
|
+
// stored as plaintext. A future improvement should encrypt these at rest.
|
|
609
|
+
// All API responses MUST mask sensitive fields via maskChannelConfig() before returning.
|
|
610
|
+
config: text("config").notNull(), // JSON: { webhookUrl?, botToken?, chatId?, channelId?, signingSecret?, webhookSecret? }
|
|
608
611
|
status: text("status", { enum: ["active", "disabled"] }).default("active").notNull(),
|
|
609
612
|
testStatus: text("test_status", { enum: ["untested", "ok", "failed"] }).default("untested").notNull(),
|
|
610
613
|
direction: text("direction", { enum: ["outbound", "bidirectional"] }).default("outbound").notNull(),
|
package/src/lib/docs/reader.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { readFileSync, readdirSync, existsSync } from "fs";
|
|
|
2
2
|
import { join, basename } from "path";
|
|
3
3
|
import type { DocManifest, ParsedDoc } from "./types";
|
|
4
4
|
|
|
5
|
-
/** Resolve the docs directory relative to
|
|
5
|
+
/** Resolve the docs directory relative to this source file (npx-safe) */
|
|
6
6
|
function docsDir(): string {
|
|
7
|
-
|
|
7
|
+
const dir = import.meta.dirname ?? __dirname;
|
|
8
|
+
// src/lib/docs/ → project root → docs/
|
|
9
|
+
return join(dir, "..", "..", "..", "docs");
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/** Read and parse docs/manifest.json */
|
|
@@ -3,16 +3,20 @@ import type { ProcessorResult } from "../registry";
|
|
|
3
3
|
|
|
4
4
|
/** Parse XLSX/CSV to a text table representation */
|
|
5
5
|
export async function processSpreadsheet(filePath: string): Promise<ProcessorResult> {
|
|
6
|
-
const
|
|
6
|
+
const ExcelJS = await import("exceljs");
|
|
7
|
+
const workbook = new ExcelJS.Workbook();
|
|
7
8
|
const buffer = await readFile(filePath);
|
|
8
|
-
|
|
9
|
+
await workbook.xlsx.load(buffer as unknown as Buffer);
|
|
9
10
|
|
|
10
11
|
const sheets: string[] = [];
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
workbook.eachSheet((worksheet) => {
|
|
13
|
+
const rows: string[] = [];
|
|
14
|
+
worksheet.eachRow((row) => {
|
|
15
|
+
const values = (row.values as (string | number | null | undefined)[]).slice(1); // ExcelJS is 1-indexed
|
|
16
|
+
rows.push(values.map((v) => (v ?? "").toString()).join(","));
|
|
17
|
+
});
|
|
18
|
+
sheets.push(`--- Sheet: ${worksheet.name} ---\n${rows.join("\n")}`);
|
|
19
|
+
});
|
|
16
20
|
|
|
17
21
|
return { extractedText: sheets.join("\n\n") };
|
|
18
22
|
}
|
package/src/app/apple-icon.tsx
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { ImageResponse } from "next/og";
|
|
2
|
-
import { readFileSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
|
|
5
|
-
export const size = { width: 180, height: 180 };
|
|
6
|
-
export const contentType = "image/png";
|
|
7
|
-
|
|
8
|
-
export default function AppleIcon() {
|
|
9
|
-
const logoData = readFileSync(join(process.cwd(), "public/stagent-s-128.png"));
|
|
10
|
-
const logoSrc = `data:image/png;base64,${logoData.toString("base64")}`;
|
|
11
|
-
|
|
12
|
-
return new ImageResponse(
|
|
13
|
-
(
|
|
14
|
-
<div
|
|
15
|
-
style={{
|
|
16
|
-
width: "100%",
|
|
17
|
-
height: "100%",
|
|
18
|
-
display: "flex",
|
|
19
|
-
alignItems: "center",
|
|
20
|
-
justifyContent: "center",
|
|
21
|
-
background: "#0f172a",
|
|
22
|
-
borderRadius: "36px",
|
|
23
|
-
}}
|
|
24
|
-
>
|
|
25
|
-
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
26
|
-
<img src={logoSrc} width="130" height="130" alt="" />
|
|
27
|
-
</div>
|
|
28
|
-
),
|
|
29
|
-
{ ...size }
|
|
30
|
-
);
|
|
31
|
-
}
|
package/src/app/icon.tsx
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { ImageResponse } from "next/og";
|
|
2
|
-
import { readFileSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
|
|
5
|
-
export const size = { width: 32, height: 32 };
|
|
6
|
-
export const contentType = "image/png";
|
|
7
|
-
|
|
8
|
-
export default function Icon() {
|
|
9
|
-
const logoData = readFileSync(join(process.cwd(), "public/stagent-s-64.png"));
|
|
10
|
-
const logoSrc = `data:image/png;base64,${logoData.toString("base64")}`;
|
|
11
|
-
|
|
12
|
-
return new ImageResponse(
|
|
13
|
-
(
|
|
14
|
-
<div
|
|
15
|
-
style={{
|
|
16
|
-
width: "100%",
|
|
17
|
-
height: "100%",
|
|
18
|
-
display: "flex",
|
|
19
|
-
alignItems: "center",
|
|
20
|
-
justifyContent: "center",
|
|
21
|
-
background: "transparent",
|
|
22
|
-
}}
|
|
23
|
-
>
|
|
24
|
-
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
25
|
-
<img src={logoSrc} width="30" height="30" alt="" />
|
|
26
|
-
</div>
|
|
27
|
-
),
|
|
28
|
-
{ ...size }
|
|
29
|
-
);
|
|
30
|
-
}
|