business-stack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.python-version +1 -0
- package/backend/.env.example +65 -0
- package/backend/alembic/env.py +63 -0
- package/backend/alembic/script.py.mako +26 -0
- package/backend/alembic/versions/2a9c8f1d0e7b_multimodal_kb_schema.py +279 -0
- package/backend/alembic/versions/3c1d2e4f5a6b_sqlite_vec_embeddings.py +58 -0
- package/backend/alembic/versions/4e8b0c2d1a3f_document_links.py +50 -0
- package/backend/alembic/versions/6a0b1c2d3e4f_link_expansion_dedupe_columns.py +49 -0
- package/backend/alembic/versions/7d8e9f0a1b2c_document_chunks.py +70 -0
- package/backend/alembic/versions/8f2a1c0d9e3b_initial_empty_revision.py +22 -0
- package/backend/alembic/versions/9f0a1b2c3d4e_entity_mentions_cooccurrence.py +123 -0
- package/backend/alembic/versions/b1c2d3e4f5a6_pipeline_dedupe_dlq.py +99 -0
- package/backend/alembic/versions/c2d3e4f5061a_chat_sessions_messages.py +59 -0
- package/backend/alembic.ini +42 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/config.py +337 -0
- package/backend/app/connectors/__init__.py +13 -0
- package/backend/app/connectors/base.py +39 -0
- package/backend/app/connectors/builtins.py +51 -0
- package/backend/app/connectors/playwright_session.py +146 -0
- package/backend/app/connectors/registry.py +68 -0
- package/backend/app/connectors/thread_expansion/__init__.py +33 -0
- package/backend/app/connectors/thread_expansion/fakes.py +154 -0
- package/backend/app/connectors/thread_expansion/models.py +113 -0
- package/backend/app/connectors/thread_expansion/reddit.py +53 -0
- package/backend/app/connectors/thread_expansion/twitter.py +49 -0
- package/backend/app/db.py +5 -0
- package/backend/app/dependencies.py +34 -0
- package/backend/app/logging_config.py +35 -0
- package/backend/app/main.py +97 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/gateway_identity.py +17 -0
- package/backend/app/middleware/openapi_gateway.py +71 -0
- package/backend/app/middleware/request_id.py +23 -0
- package/backend/app/openapi_config.py +126 -0
- package/backend/app/routers/__init__.py +0 -0
- package/backend/app/routers/admin_pipeline.py +123 -0
- package/backend/app/routers/chat.py +206 -0
- package/backend/app/routers/chunks.py +36 -0
- package/backend/app/routers/entity_extract.py +31 -0
- package/backend/app/routers/example.py +8 -0
- package/backend/app/routers/gemini_embed.py +58 -0
- package/backend/app/routers/health.py +28 -0
- package/backend/app/routers/ingestion.py +146 -0
- package/backend/app/routers/link_expansion.py +34 -0
- package/backend/app/routers/pipeline_status.py +304 -0
- package/backend/app/routers/query.py +63 -0
- package/backend/app/routers/vectors.py +63 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/canonical.py +44 -0
- package/backend/app/schemas/chat.py +50 -0
- package/backend/app/schemas/ingest.py +29 -0
- package/backend/app/schemas/query.py +153 -0
- package/backend/app/schemas/vectors.py +56 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/chat_store.py +152 -0
- package/backend/app/services/chunking/__init__.py +3 -0
- package/backend/app/services/chunking/llm_boundaries.py +63 -0
- package/backend/app/services/chunking/schemas.py +30 -0
- package/backend/app/services/chunking/semantic_chunk.py +178 -0
- package/backend/app/services/chunking/splitters.py +214 -0
- package/backend/app/services/embeddings/__init__.py +20 -0
- package/backend/app/services/embeddings/build_inputs.py +140 -0
- package/backend/app/services/embeddings/dlq.py +128 -0
- package/backend/app/services/embeddings/gemini_api.py +207 -0
- package/backend/app/services/embeddings/persist.py +74 -0
- package/backend/app/services/embeddings/types.py +32 -0
- package/backend/app/services/embeddings/worker.py +224 -0
- package/backend/app/services/entities/__init__.py +12 -0
- package/backend/app/services/entities/gliner_extract.py +63 -0
- package/backend/app/services/entities/llm_extract.py +94 -0
- package/backend/app/services/entities/pipeline.py +179 -0
- package/backend/app/services/entities/spacy_extract.py +63 -0
- package/backend/app/services/entities/types.py +15 -0
- package/backend/app/services/gemini_chat.py +113 -0
- package/backend/app/services/hooks/__init__.py +3 -0
- package/backend/app/services/hooks/post_ingest.py +186 -0
- package/backend/app/services/ingestion/__init__.py +0 -0
- package/backend/app/services/ingestion/persist.py +188 -0
- package/backend/app/services/integrations_remote.py +91 -0
- package/backend/app/services/link_expansion/__init__.py +3 -0
- package/backend/app/services/link_expansion/canonical_url.py +45 -0
- package/backend/app/services/link_expansion/domain_policy.py +26 -0
- package/backend/app/services/link_expansion/html_extract.py +72 -0
- package/backend/app/services/link_expansion/rate_limit.py +32 -0
- package/backend/app/services/link_expansion/robots.py +46 -0
- package/backend/app/services/link_expansion/schemas.py +67 -0
- package/backend/app/services/link_expansion/worker.py +458 -0
- package/backend/app/services/normalization/__init__.py +7 -0
- package/backend/app/services/normalization/normalizer.py +331 -0
- package/backend/app/services/normalization/persist_normalized.py +67 -0
- package/backend/app/services/playwright_extract/__init__.py +13 -0
- package/backend/app/services/playwright_extract/__main__.py +96 -0
- package/backend/app/services/playwright_extract/extract.py +181 -0
- package/backend/app/services/retrieval_service.py +351 -0
- package/backend/app/sqlite_ext.py +36 -0
- package/backend/app/storage/__init__.py +3 -0
- package/backend/app/storage/blobs.py +30 -0
- package/backend/app/vectorstore/__init__.py +13 -0
- package/backend/app/vectorstore/sqlite_vec_store.py +242 -0
- package/backend/backend.egg-info/PKG-INFO +18 -0
- package/backend/backend.egg-info/SOURCES.txt +93 -0
- package/backend/backend.egg-info/dependency_links.txt +1 -0
- package/backend/backend.egg-info/entry_points.txt +2 -0
- package/backend/backend.egg-info/requires.txt +15 -0
- package/backend/backend.egg-info/top_level.txt +4 -0
- package/backend/package.json +15 -0
- package/backend/pyproject.toml +52 -0
- package/backend/tests/conftest.py +40 -0
- package/backend/tests/test_chat.py +92 -0
- package/backend/tests/test_chunking.py +132 -0
- package/backend/tests/test_entities.py +170 -0
- package/backend/tests/test_gemini_embed.py +224 -0
- package/backend/tests/test_health.py +24 -0
- package/backend/tests/test_ingest_raw.py +123 -0
- package/backend/tests/test_link_expansion.py +241 -0
- package/backend/tests/test_main.py +12 -0
- package/backend/tests/test_normalizer.py +114 -0
- package/backend/tests/test_openapi_gateway.py +40 -0
- package/backend/tests/test_pipeline_hardening.py +285 -0
- package/backend/tests/test_pipeline_status.py +71 -0
- package/backend/tests/test_playwright_extract.py +80 -0
- package/backend/tests/test_post_ingest_hooks.py +162 -0
- package/backend/tests/test_query.py +165 -0
- package/backend/tests/test_thread_expansion.py +72 -0
- package/backend/tests/test_vectors.py +85 -0
- package/backend/uv.lock +1839 -0
- package/bin/business-stack.cjs +412 -0
- package/frontend/web/.env.example +23 -0
- package/frontend/web/AGENTS.md +5 -0
- package/frontend/web/CLAUDE.md +1 -0
- package/frontend/web/README.md +36 -0
- package/frontend/web/components.json +25 -0
- package/frontend/web/next-env.d.ts +6 -0
- package/frontend/web/next.config.ts +30 -0
- package/frontend/web/package.json +65 -0
- package/frontend/web/postcss.config.mjs +7 -0
- package/frontend/web/skills-lock.json +35 -0
- package/frontend/web/src/app/account/[[...path]]/page.tsx +19 -0
- package/frontend/web/src/app/auth/[[...path]]/page.tsx +14 -0
- package/frontend/web/src/app/chat/page.tsx +725 -0
- package/frontend/web/src/app/favicon.ico +0 -0
- package/frontend/web/src/app/globals.css +563 -0
- package/frontend/web/src/app/layout.tsx +50 -0
- package/frontend/web/src/app/page.tsx +96 -0
- package/frontend/web/src/app/settings/integrations/actions.ts +74 -0
- package/frontend/web/src/app/settings/integrations/integrations-settings-form.tsx +330 -0
- package/frontend/web/src/app/settings/integrations/page.tsx +41 -0
- package/frontend/web/src/app/webhooks/alpha-alerts/route.ts +84 -0
- package/frontend/web/src/components/home-auth-panel.tsx +49 -0
- package/frontend/web/src/components/providers.tsx +50 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/registry.ts +35 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/types.ts +8 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.test.ts +40 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.ts +78 -0
- package/frontend/web/src/lib/alpha-webhook/connectors/wabridge.ts +30 -0
- package/frontend/web/src/lib/alpha-webhook/handler.ts +12 -0
- package/frontend/web/src/lib/alpha-webhook/signature.test.ts +33 -0
- package/frontend/web/src/lib/alpha-webhook/signature.ts +21 -0
- package/frontend/web/src/lib/alpha-webhook/types.ts +23 -0
- package/frontend/web/src/lib/auth-client.ts +23 -0
- package/frontend/web/src/lib/integrations-config.ts +125 -0
- package/frontend/web/src/lib/ui-utills.tsx +90 -0
- package/frontend/web/src/lib/utils.ts +6 -0
- package/frontend/web/tsconfig.json +36 -0
- package/frontend/web/tsconfig.tsbuildinfo +1 -0
- package/frontend/web/vitest.config.ts +14 -0
- package/gateway/.env.example +23 -0
- package/gateway/README.md +13 -0
- package/gateway/package.json +24 -0
- package/gateway/src/auth.ts +49 -0
- package/gateway/src/index.ts +141 -0
- package/gateway/src/integrations/admin.ts +19 -0
- package/gateway/src/integrations/crypto.ts +52 -0
- package/gateway/src/integrations/handlers.ts +124 -0
- package/gateway/src/integrations/keys.ts +12 -0
- package/gateway/src/integrations/store.ts +106 -0
- package/gateway/src/stack-secrets.ts +35 -0
- package/gateway/tsconfig.json +13 -0
- package/package.json +33 -0
- package/turbo.json +27 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
6
|
+
const crypto = require("node:crypto");
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
const readline = require("node:readline");
|
|
10
|
+
const { program } = require("commander");
|
|
11
|
+
|
|
12
|
+
function getStackRoot() {
|
|
13
|
+
let dir = path.resolve(__dirname, "..");
|
|
14
|
+
for (;;) {
|
|
15
|
+
const turbo = path.join(dir, "turbo.json");
|
|
16
|
+
const gw = path.join(dir, "gateway", "package.json");
|
|
17
|
+
if (fs.existsSync(turbo) && fs.existsSync(gw)) {
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
const parent = path.dirname(dir);
|
|
21
|
+
if (parent === dir) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"Could not find business-stack root (turbo.json + gateway). Reinstall the package or run from the monorepo root.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireBetterSqlite() {
|
|
31
|
+
try {
|
|
32
|
+
return require("better-sqlite3");
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(
|
|
35
|
+
"better-sqlite3 is required. From the business-stack package directory run: npm install",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function gatewayAuthDbPath(stackRoot) {
|
|
41
|
+
return path.join(stackRoot, "gateway", "auth.sqlite");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasExistingSecrets(stackRoot) {
|
|
45
|
+
const dbPath = gatewayAuthDbPath(stackRoot);
|
|
46
|
+
if (!fs.existsSync(dbPath)) return false;
|
|
47
|
+
const Database = requireBetterSqlite();
|
|
48
|
+
const db = new Database(dbPath, { readonly: true });
|
|
49
|
+
try {
|
|
50
|
+
const row = db
|
|
51
|
+
.prepare(
|
|
52
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='stack_secrets'",
|
|
53
|
+
)
|
|
54
|
+
.get();
|
|
55
|
+
if (!row) return false;
|
|
56
|
+
const n = db.prepare("SELECT COUNT(*) AS c FROM stack_secrets").get();
|
|
57
|
+
return n && n.c > 0;
|
|
58
|
+
} finally {
|
|
59
|
+
db.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readStackSecretsEnv(stackRoot) {
|
|
64
|
+
const dbPath = gatewayAuthDbPath(stackRoot);
|
|
65
|
+
if (!fs.existsSync(dbPath)) return {};
|
|
66
|
+
const Database = requireBetterSqlite();
|
|
67
|
+
const db = new Database(dbPath, { readonly: true });
|
|
68
|
+
try {
|
|
69
|
+
const row = db
|
|
70
|
+
.prepare(
|
|
71
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='stack_secrets'",
|
|
72
|
+
)
|
|
73
|
+
.get();
|
|
74
|
+
if (!row) return {};
|
|
75
|
+
const rows = db.prepare("SELECT key, value FROM stack_secrets").all();
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const { key, value } of rows) {
|
|
78
|
+
if (key && value != null && String(value).trim()) {
|
|
79
|
+
out[key] = String(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
} finally {
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ensureStackSecretsTable(db) {
|
|
89
|
+
db.exec(`
|
|
90
|
+
CREATE TABLE IF NOT EXISTS stack_secrets (
|
|
91
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
92
|
+
value TEXT NOT NULL
|
|
93
|
+
)
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function upsertSecrets(db, entries) {
|
|
98
|
+
const stmt = db.prepare(
|
|
99
|
+
"INSERT OR REPLACE INTO stack_secrets (key, value) VALUES (?, ?)",
|
|
100
|
+
);
|
|
101
|
+
const run = db.transaction((list) => {
|
|
102
|
+
for (const [k, v] of list) {
|
|
103
|
+
stmt.run(k, v);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
run(entries);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeEnvFile(filePath, content) {
|
|
110
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
111
|
+
fs.writeFileSync(filePath, `${content.trim()}\n`, "utf8");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cmdExists(cmd) {
|
|
115
|
+
const isWin = process.platform === "win32";
|
|
116
|
+
const which = isWin ? "where" : "which";
|
|
117
|
+
const r = spawnSync(which, [cmd], { encoding: "utf8" });
|
|
118
|
+
return r.status === 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runDoctor() {
|
|
122
|
+
const ok = [];
|
|
123
|
+
const bad = [];
|
|
124
|
+
if (cmdExists("bun")) ok.push("bun");
|
|
125
|
+
else bad.push("bun (https://bun.sh)");
|
|
126
|
+
if (cmdExists("uv")) ok.push("uv");
|
|
127
|
+
else bad.push("uv (https://docs.astral.sh/uv/)");
|
|
128
|
+
if (cmdExists("python") || cmdExists("python3")) ok.push("python");
|
|
129
|
+
else bad.push("python 3.12+");
|
|
130
|
+
console.log("business-stack doctor\n");
|
|
131
|
+
if (ok.length) console.log("Found:", ok.join(", "));
|
|
132
|
+
if (bad.length) {
|
|
133
|
+
console.log("\nMissing (install for full stack):");
|
|
134
|
+
for (const b of bad) console.log(` - ${b}`);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.log("\nAll common toolchain binaries are on PATH.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function promptDefaults(rl, defaults) {
|
|
142
|
+
const web = await question(rl, "Next public URL (browser)", defaults.webUrl);
|
|
143
|
+
const gw = await question(rl, "Gateway URL (internal)", defaults.gatewayUrl);
|
|
144
|
+
const be = await question(rl, "Backend URL (internal)", defaults.backendUrl);
|
|
145
|
+
const gwPort = new URL(gw).port || "3001";
|
|
146
|
+
return {
|
|
147
|
+
webUrl: web.replace(/\/$/, ""),
|
|
148
|
+
gatewayUrl: gw.replace(/\/$/, ""),
|
|
149
|
+
backendUrl: be.replace(/\/$/, ""),
|
|
150
|
+
gatewayPort: gwPort,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function question(rl, label, def) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
rl.question(`${label} [${def}]: `, (ans) => {
|
|
157
|
+
resolve((ans || "").trim() || def);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function runSetup(opts) {
|
|
163
|
+
const stackRoot = getStackRoot();
|
|
164
|
+
if (hasExistingSecrets(stackRoot) && !opts.force) {
|
|
165
|
+
console.error(
|
|
166
|
+
"stack_secrets already exists. Use --force to overwrite (dangerous).",
|
|
167
|
+
);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const defaults = {
|
|
172
|
+
webUrl: "http://127.0.0.1:3000",
|
|
173
|
+
gatewayUrl: "http://127.0.0.1:3001",
|
|
174
|
+
backendUrl: "http://127.0.0.1:8000",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let urls;
|
|
178
|
+
if (opts.yes) {
|
|
179
|
+
const gatewayUrl = defaults.gatewayUrl.replace(/\/$/, "");
|
|
180
|
+
const u = new URL(gatewayUrl);
|
|
181
|
+
urls = {
|
|
182
|
+
webUrl: defaults.webUrl.replace(/\/$/, ""),
|
|
183
|
+
gatewayUrl,
|
|
184
|
+
backendUrl: defaults.backendUrl.replace(/\/$/, ""),
|
|
185
|
+
gatewayPort: u.port || "3001",
|
|
186
|
+
};
|
|
187
|
+
} else {
|
|
188
|
+
const rl = readline.createInterface({
|
|
189
|
+
input: process.stdin,
|
|
190
|
+
output: process.stdout,
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
urls = await promptDefaults(rl, defaults);
|
|
194
|
+
} finally {
|
|
195
|
+
rl.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const webOrigin = urls.webUrl;
|
|
200
|
+
const trusted = `${webOrigin},http://127.0.0.1:3000,http://localhost:3000`;
|
|
201
|
+
|
|
202
|
+
const betterAuthSecret = crypto.randomBytes(32).toString("base64");
|
|
203
|
+
const integrationsEncryptionKey = crypto.randomBytes(32).toString("hex");
|
|
204
|
+
const integrationsInternalSecret = crypto.randomBytes(32).toString("hex");
|
|
205
|
+
const backendGatewaySecret = crypto.randomBytes(32).toString("base64");
|
|
206
|
+
|
|
207
|
+
const dbPath = gatewayAuthDbPath(stackRoot);
|
|
208
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
209
|
+
const Database = requireBetterSqlite();
|
|
210
|
+
const db = new Database(dbPath);
|
|
211
|
+
try {
|
|
212
|
+
ensureStackSecretsTable(db);
|
|
213
|
+
upsertSecrets(db, [
|
|
214
|
+
["BETTER_AUTH_SECRET", betterAuthSecret],
|
|
215
|
+
["INTEGRATIONS_ENCRYPTION_KEY", integrationsEncryptionKey],
|
|
216
|
+
["INTEGRATIONS_INTERNAL_SECRET", integrationsInternalSecret],
|
|
217
|
+
["BACKEND_GATEWAY_SECRET", backendGatewaySecret],
|
|
218
|
+
["BETTER_AUTH_URL", webOrigin],
|
|
219
|
+
["BETTER_AUTH_TRUSTED_ORIGINS", trusted],
|
|
220
|
+
["CORS_ORIGIN", webOrigin],
|
|
221
|
+
["BACKEND_ORIGIN", urls.backendUrl],
|
|
222
|
+
["PORT", String(urls.gatewayPort)],
|
|
223
|
+
]);
|
|
224
|
+
} finally {
|
|
225
|
+
db.close();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const gwEnv = `# Generated by business-stack setup — do not commit
|
|
229
|
+
BETTER_AUTH_URL=${webOrigin}
|
|
230
|
+
BETTER_AUTH_SECRET=${betterAuthSecret}
|
|
231
|
+
BETTER_AUTH_TRUSTED_ORIGINS=${trusted}
|
|
232
|
+
BACKEND_GATEWAY_SECRET=${backendGatewaySecret}
|
|
233
|
+
INTEGRATIONS_ENCRYPTION_KEY=${integrationsEncryptionKey}
|
|
234
|
+
INTEGRATIONS_INTERNAL_SECRET=${integrationsInternalSecret}
|
|
235
|
+
CORS_ORIGIN=${webOrigin}
|
|
236
|
+
BACKEND_ORIGIN=${urls.backendUrl}
|
|
237
|
+
PORT=${urls.gatewayPort}
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
writeEnvFile(path.join(stackRoot, "gateway", ".env"), gwEnv);
|
|
241
|
+
|
|
242
|
+
const webEnv = `# Next.js — generated by business-stack setup
|
|
243
|
+
NEXT_PUBLIC_AUTH_BASE_URL=${webOrigin}
|
|
244
|
+
NEXT_PUBLIC_APP_URL=${webOrigin}
|
|
245
|
+
AUTH_GATEWAY_INTERNAL_URL=${urls.gatewayUrl}
|
|
246
|
+
INTEGRATIONS_INTERNAL_SECRET=${integrationsInternalSecret}
|
|
247
|
+
`;
|
|
248
|
+
writeEnvFile(path.join(stackRoot, "frontend", "web", ".env.local"), webEnv);
|
|
249
|
+
|
|
250
|
+
const backendEnv = `# FastAPI — generated by business-stack setup
|
|
251
|
+
DATA_DIR=data
|
|
252
|
+
SQLITE_FILENAME=rag.sqlite
|
|
253
|
+
DATABASE_URL=sqlite+aiosqlite:///./data/rag.sqlite
|
|
254
|
+
INTEGRATIONS_GATEWAY_URL=${urls.gatewayUrl}
|
|
255
|
+
INTEGRATIONS_INTERNAL_SECRET=${integrationsInternalSecret}
|
|
256
|
+
BACKEND_GATEWAY_SECRET=${backendGatewaySecret}
|
|
257
|
+
`;
|
|
258
|
+
writeEnvFile(path.join(stackRoot, "backend", ".env"), backendEnv);
|
|
259
|
+
|
|
260
|
+
console.log("Setup complete.");
|
|
261
|
+
console.log(` SQLite: ${dbPath} (table stack_secrets)`);
|
|
262
|
+
console.log(" Wrote gateway/.env, frontend/web/.env.local, backend/.env");
|
|
263
|
+
console.log("Run: business-stack start");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function envFileLooksConfigured(stackRoot) {
|
|
267
|
+
const g = path.join(stackRoot, "gateway", ".env");
|
|
268
|
+
if (fs.existsSync(g)) {
|
|
269
|
+
const t = fs.readFileSync(g, "utf8");
|
|
270
|
+
if (/BETTER_AUTH_SECRET=\S+/.test(t) || /INTEGRATIONS_ENCRYPTION_KEY=\S+/.test(t)) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return hasExistingSecrets(stackRoot);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function runCmd(cmd, args, cwd, extraEnv) {
|
|
278
|
+
const r = spawnSync(cmd, args, {
|
|
279
|
+
cwd,
|
|
280
|
+
stdio: "inherit",
|
|
281
|
+
env: { ...process.env, ...extraEnv },
|
|
282
|
+
shell: false,
|
|
283
|
+
});
|
|
284
|
+
if (r.status !== 0) {
|
|
285
|
+
process.exit(r.status ?? 1);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function runStart(opts) {
|
|
290
|
+
const stackRoot = getStackRoot();
|
|
291
|
+
if (!envFileLooksConfigured(stackRoot)) {
|
|
292
|
+
console.error(
|
|
293
|
+
"Stack is not configured. Run: business-stack setup\n(or create gateway/.env and secrets manually)",
|
|
294
|
+
);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const fromDb = readStackSecretsEnv(stackRoot);
|
|
299
|
+
const childEnv = { ...process.env };
|
|
300
|
+
for (const [k, v] of Object.entries(fromDb)) {
|
|
301
|
+
if (!process.env[k]?.trim() && String(v).trim()) {
|
|
302
|
+
childEnv[k] = String(v);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!opts.skipInstall) {
|
|
307
|
+
runCmd("bun", ["install"], stackRoot, childEnv);
|
|
308
|
+
}
|
|
309
|
+
runCmd("uv", ["sync"], path.join(stackRoot, "backend"), childEnv);
|
|
310
|
+
if (!opts.skipBuild) {
|
|
311
|
+
runCmd("bun", ["run", "build"], stackRoot, childEnv);
|
|
312
|
+
}
|
|
313
|
+
if (!opts.skipMigrate) {
|
|
314
|
+
runCmd("uv", ["run", "alembic", "upgrade", "head"], path.join(stackRoot, "backend"), childEnv);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const child = spawn("bun", ["run", "start"], {
|
|
318
|
+
cwd: stackRoot,
|
|
319
|
+
stdio: "inherit",
|
|
320
|
+
env: childEnv,
|
|
321
|
+
shell: false,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
function shutdown(signal) {
|
|
325
|
+
try {
|
|
326
|
+
if (process.platform === "win32") {
|
|
327
|
+
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
|
328
|
+
stdio: "ignore",
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
child.kill(signal);
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
/* ignore */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
339
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
340
|
+
child.on("exit", (code, sig) => {
|
|
341
|
+
if (sig) process.exit(1);
|
|
342
|
+
process.exit(code ?? 0);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function runDev() {
|
|
347
|
+
const stackRoot = getStackRoot();
|
|
348
|
+
const fromDb = readStackSecretsEnv(stackRoot);
|
|
349
|
+
const childEnv = { ...process.env };
|
|
350
|
+
for (const [k, v] of Object.entries(fromDb)) {
|
|
351
|
+
if (!process.env[k]?.trim() && String(v).trim()) {
|
|
352
|
+
childEnv[k] = String(v);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
runCmd("bun", ["run", "dev"], stackRoot, childEnv);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
program
|
|
359
|
+
.name("business-stack")
|
|
360
|
+
.description("Run the business-stack monorepo (Next + Hono gateway + FastAPI)")
|
|
361
|
+
.version("0.1.0");
|
|
362
|
+
|
|
363
|
+
program
|
|
364
|
+
.command("doctor")
|
|
365
|
+
.description("Check for bun, uv, and python on PATH")
|
|
366
|
+
.action(runDoctor);
|
|
367
|
+
|
|
368
|
+
program
|
|
369
|
+
.command("setup")
|
|
370
|
+
.description("Generate secrets, save to gateway/auth.sqlite and .env files")
|
|
371
|
+
.option("-y, --yes", "Use default URLs (no prompts)")
|
|
372
|
+
.option("--force", "Overwrite existing stack_secrets")
|
|
373
|
+
.action(async (o) => {
|
|
374
|
+
try {
|
|
375
|
+
await runSetup(o);
|
|
376
|
+
} catch (e) {
|
|
377
|
+
console.error(e instanceof Error ? e.message : e);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
program
|
|
383
|
+
.command("start")
|
|
384
|
+
.description("Install deps, build, migrate, run production stack (turbo start)")
|
|
385
|
+
.option("--skip-install", "Skip bun install")
|
|
386
|
+
.option("--skip-build", "Skip turbo build")
|
|
387
|
+
.option("--skip-migrate", "Skip alembic upgrade")
|
|
388
|
+
.action((o) => {
|
|
389
|
+
try {
|
|
390
|
+
runStart(o);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
console.error(e instanceof Error ? e.message : e);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
program
|
|
398
|
+
.command("dev")
|
|
399
|
+
.description("Run turbo dev (development servers)")
|
|
400
|
+
.action(() => {
|
|
401
|
+
try {
|
|
402
|
+
runDev();
|
|
403
|
+
} catch (e) {
|
|
404
|
+
console.error(e instanceof Error ? e.message : e);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
program.parseAsync(process.argv).catch((e) => {
|
|
410
|
+
console.error(e);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# --- Browser / app URLs (system) ---
|
|
2
|
+
# Same origin as the Next app: the browser hits /api/auth/* on this host; Next rewrites to the gateway.
|
|
3
|
+
NEXT_PUBLIC_AUTH_BASE_URL=http://localhost:3000
|
|
4
|
+
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
5
|
+
|
|
6
|
+
# --- Next server → gateway (system) ---
|
|
7
|
+
# Where Next proxies /api/auth/*, /api/backend/*, /api/integrations/* (gateway URL when running locally).
|
|
8
|
+
AUTH_GATEWAY_INTERNAL_URL=http://127.0.0.1:3001
|
|
9
|
+
|
|
10
|
+
# Bearer secret shared with the gateway. Next calls GET /internal/integrations (webhooks, WABridge, optional GEMINI fallback).
|
|
11
|
+
# Must match gateway INTEGRATIONS_INTERNAL_SECRET. openssl rand -base64 32
|
|
12
|
+
INTEGRATIONS_INTERNAL_SECRET=
|
|
13
|
+
|
|
14
|
+
# --- Optional env fallbacks (same keys as Integration settings UI / gateway SQLite) ---
|
|
15
|
+
# Used when INTEGRATIONS_INTERNAL_SECRET is unset, or to override / backfill per field after a gateway read.
|
|
16
|
+
# GEMINI_API_KEY=
|
|
17
|
+
# ALPHA_WEBHOOK_SECRET=
|
|
18
|
+
# WABRIDGE_BASE_URL=http://127.0.0.1:8080
|
|
19
|
+
# WABRIDGE_PHONE=
|
|
20
|
+
# Set to 0 or false to force-disable WABridge regardless of stored settings.
|
|
21
|
+
# WABRIDGE_ENABLED=true
|
|
22
|
+
# Optional: fetch timeout in ms (default 60000). Remote WABridge/WhatsApp can exceed 25s; clamped 5000–120000.
|
|
23
|
+
# WABRIDGE_FETCH_TIMEOUT_MS=90000
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
2
|
+
# This is NOT the Next.js you know
|
|
3
|
+
|
|
4
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
|
5
|
+
<!-- END:nextjs-agent-rules -->
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
2
|
+
|
|
3
|
+
## Getting Started
|
|
4
|
+
|
|
5
|
+
First, run the development server:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run dev
|
|
9
|
+
# or
|
|
10
|
+
yarn dev
|
|
11
|
+
# or
|
|
12
|
+
pnpm dev
|
|
13
|
+
# or
|
|
14
|
+
bun dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
18
|
+
|
|
19
|
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
20
|
+
|
|
21
|
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
22
|
+
|
|
23
|
+
## Learn More
|
|
24
|
+
|
|
25
|
+
To learn more about Next.js, take a look at the following resources:
|
|
26
|
+
|
|
27
|
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
28
|
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
29
|
+
|
|
30
|
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
31
|
+
|
|
32
|
+
## Deploy on Vercel
|
|
33
|
+
|
|
34
|
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
35
|
+
|
|
36
|
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "radix-lyra",
|
|
4
|
+
"rsc": true,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/app/globals.css",
|
|
9
|
+
"baseColor": "zinc",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"iconLibrary": "hugeicons",
|
|
14
|
+
"rtl": false,
|
|
15
|
+
"aliases": {
|
|
16
|
+
"components": "@/components",
|
|
17
|
+
"utils": "@/lib/utils",
|
|
18
|
+
"ui": "@/components/ui",
|
|
19
|
+
"lib": "@/lib",
|
|
20
|
+
"hooks": "@/hooks"
|
|
21
|
+
},
|
|
22
|
+
"menuColor": "inverted-translucent",
|
|
23
|
+
"menuAccent": "subtle",
|
|
24
|
+
"registries": {}
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
const gatewayInternal =
|
|
4
|
+
process.env.AUTH_GATEWAY_INTERNAL_URL?.trim() || "http://127.0.0.1:3001";
|
|
5
|
+
|
|
6
|
+
const nextConfig: NextConfig = {
|
|
7
|
+
reactCompiler: true,
|
|
8
|
+
async rewrites() {
|
|
9
|
+
return [
|
|
10
|
+
{
|
|
11
|
+
source: "/api/auth/:path*",
|
|
12
|
+
destination: `${gatewayInternal}/api/auth/:path*`,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
source: "/api/gateway/session",
|
|
16
|
+
destination: `${gatewayInternal}/session`,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
source: "/api/backend/:path*",
|
|
20
|
+
destination: `${gatewayInternal}/api/backend/:path*`,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
source: "/api/integrations/:path*",
|
|
24
|
+
destination: `${gatewayInternal}/api/integrations/:path*`,
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default nextConfig;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@business-stack/web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"packageManager": "bun@1.3.1",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start",
|
|
10
|
+
"lint": "biome check .",
|
|
11
|
+
"lint:fix": "biome check --write .",
|
|
12
|
+
"format": "biome format --write",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"clean": "bun x rimraf@6 .next"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@captchafox/react": "^1.11.0",
|
|
19
|
+
"@daveyplate/better-auth-ui": "^3.4.0",
|
|
20
|
+
"@hookform/resolvers": "^5.2.2",
|
|
21
|
+
"@hugeicons/core-free-icons": "^4.1.1",
|
|
22
|
+
"@hugeicons/react": "^1.1.6",
|
|
23
|
+
"@marsidev/react-turnstile": "^1.5.0",
|
|
24
|
+
"@radix-ui/react-avatar": "^1.1.11",
|
|
25
|
+
"@radix-ui/react-checkbox": "^1.3.3",
|
|
26
|
+
"@radix-ui/react-context": "^1.1.3",
|
|
27
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
28
|
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
29
|
+
"@radix-ui/react-label": "^2.1.8",
|
|
30
|
+
"@radix-ui/react-primitive": "^2.1.4",
|
|
31
|
+
"@radix-ui/react-select": "^2.2.6",
|
|
32
|
+
"@radix-ui/react-separator": "^1.1.8",
|
|
33
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
34
|
+
"@radix-ui/react-tabs": "^1.1.13",
|
|
35
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
36
|
+
"@radix-ui/react-use-callback-ref": "^1.1.1",
|
|
37
|
+
"@radix-ui/react-use-layout-effect": "^1.1.1",
|
|
38
|
+
"@tanstack/react-query": "^5.96.2",
|
|
39
|
+
"better-auth": "^1.5.6",
|
|
40
|
+
"class-variance-authority": "^0.7.1",
|
|
41
|
+
"clsx": "^2.1.1",
|
|
42
|
+
"input-otp": "^1.4.2",
|
|
43
|
+
"lucide-react": "^1.7.0",
|
|
44
|
+
"next": "16.2.2",
|
|
45
|
+
"radix-ui": "^1.4.3",
|
|
46
|
+
"react": "19.2.4",
|
|
47
|
+
"react-dom": "19.2.4",
|
|
48
|
+
"react-hook-form": "^7.72.1",
|
|
49
|
+
"server-only": "^0.0.1",
|
|
50
|
+
"shadcn": "^4.1.2",
|
|
51
|
+
"sonner": "^2.0.7",
|
|
52
|
+
"tailwind-merge": "^3.5.0",
|
|
53
|
+
"tw-animate-css": "^1.4.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@tailwindcss/postcss": "^4",
|
|
57
|
+
"@types/node": "^20",
|
|
58
|
+
"@types/react": "^19",
|
|
59
|
+
"@types/react-dom": "^19",
|
|
60
|
+
"babel-plugin-react-compiler": "1.0.0",
|
|
61
|
+
"tailwindcss": "^4",
|
|
62
|
+
"typescript": "^5",
|
|
63
|
+
"vitest": "^3.2.4"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"skills": {
|
|
4
|
+
"better-auth-best-practices": {
|
|
5
|
+
"source": "better-auth/skills",
|
|
6
|
+
"sourceType": "github",
|
|
7
|
+
"computedHash": "9ab075b5061be2a5f299c10505667345cc1ec76e8de4120901cfd586643e776f"
|
|
8
|
+
},
|
|
9
|
+
"better-auth-security-best-practices": {
|
|
10
|
+
"source": "better-auth/skills",
|
|
11
|
+
"sourceType": "github",
|
|
12
|
+
"computedHash": "ed38caef4b297cc399d717258d6ea4e6ecb8894c453c83450064c7e7c6e5cc02"
|
|
13
|
+
},
|
|
14
|
+
"create-auth-skill": {
|
|
15
|
+
"source": "better-auth/skills",
|
|
16
|
+
"sourceType": "github",
|
|
17
|
+
"computedHash": "393e8d2d795fa5797c9a4e0665f29183ea2e140233db59746d510340a10456e6"
|
|
18
|
+
},
|
|
19
|
+
"email-and-password-best-practices": {
|
|
20
|
+
"source": "better-auth/skills",
|
|
21
|
+
"sourceType": "github",
|
|
22
|
+
"computedHash": "7786d722fa682b3d6a99793e73a9d51b59becefa595676134a993020633e068f"
|
|
23
|
+
},
|
|
24
|
+
"organization-best-practices": {
|
|
25
|
+
"source": "better-auth/skills",
|
|
26
|
+
"sourceType": "github",
|
|
27
|
+
"computedHash": "fa7a76e45a1f9632e6d63e92b476cf566e64f6e93a2c8ee00bc77b4a74008fbd"
|
|
28
|
+
},
|
|
29
|
+
"two-factor-authentication-best-practices": {
|
|
30
|
+
"source": "better-auth/skills",
|
|
31
|
+
"sourceType": "github",
|
|
32
|
+
"computedHash": "c4f3a299c62c1985b774b7c28e53b7e55c565dbf973fd52b08899e4bcfffa3f9"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AccountView } from "@daveyplate/better-auth-ui";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import MaxWidthContainer from "@/lib/ui-utills";
|
|
6
|
+
|
|
7
|
+
export default function AccountPage() {
|
|
8
|
+
const pathname = usePathname();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<MaxWidthContainer
|
|
12
|
+
maxWidth="lg"
|
|
13
|
+
padding="default"
|
|
14
|
+
className="flex min-h-svh flex-col py-20"
|
|
15
|
+
>
|
|
16
|
+
<AccountView pathname={pathname} />
|
|
17
|
+
</MaxWidthContainer>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AuthView } from "@daveyplate/better-auth-ui";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
|
|
6
|
+
export default function AuthPage() {
|
|
7
|
+
const pathname = usePathname();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex min-h-svh flex-col items-center justify-center p-6">
|
|
11
|
+
<AuthView pathname={pathname} />
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|