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.
Files changed (181) hide show
  1. package/.python-version +1 -0
  2. package/backend/.env.example +65 -0
  3. package/backend/alembic/env.py +63 -0
  4. package/backend/alembic/script.py.mako +26 -0
  5. package/backend/alembic/versions/2a9c8f1d0e7b_multimodal_kb_schema.py +279 -0
  6. package/backend/alembic/versions/3c1d2e4f5a6b_sqlite_vec_embeddings.py +58 -0
  7. package/backend/alembic/versions/4e8b0c2d1a3f_document_links.py +50 -0
  8. package/backend/alembic/versions/6a0b1c2d3e4f_link_expansion_dedupe_columns.py +49 -0
  9. package/backend/alembic/versions/7d8e9f0a1b2c_document_chunks.py +70 -0
  10. package/backend/alembic/versions/8f2a1c0d9e3b_initial_empty_revision.py +22 -0
  11. package/backend/alembic/versions/9f0a1b2c3d4e_entity_mentions_cooccurrence.py +123 -0
  12. package/backend/alembic/versions/b1c2d3e4f5a6_pipeline_dedupe_dlq.py +99 -0
  13. package/backend/alembic/versions/c2d3e4f5061a_chat_sessions_messages.py +59 -0
  14. package/backend/alembic.ini +42 -0
  15. package/backend/app/__init__.py +0 -0
  16. package/backend/app/config.py +337 -0
  17. package/backend/app/connectors/__init__.py +13 -0
  18. package/backend/app/connectors/base.py +39 -0
  19. package/backend/app/connectors/builtins.py +51 -0
  20. package/backend/app/connectors/playwright_session.py +146 -0
  21. package/backend/app/connectors/registry.py +68 -0
  22. package/backend/app/connectors/thread_expansion/__init__.py +33 -0
  23. package/backend/app/connectors/thread_expansion/fakes.py +154 -0
  24. package/backend/app/connectors/thread_expansion/models.py +113 -0
  25. package/backend/app/connectors/thread_expansion/reddit.py +53 -0
  26. package/backend/app/connectors/thread_expansion/twitter.py +49 -0
  27. package/backend/app/db.py +5 -0
  28. package/backend/app/dependencies.py +34 -0
  29. package/backend/app/logging_config.py +35 -0
  30. package/backend/app/main.py +97 -0
  31. package/backend/app/middleware/__init__.py +0 -0
  32. package/backend/app/middleware/gateway_identity.py +17 -0
  33. package/backend/app/middleware/openapi_gateway.py +71 -0
  34. package/backend/app/middleware/request_id.py +23 -0
  35. package/backend/app/openapi_config.py +126 -0
  36. package/backend/app/routers/__init__.py +0 -0
  37. package/backend/app/routers/admin_pipeline.py +123 -0
  38. package/backend/app/routers/chat.py +206 -0
  39. package/backend/app/routers/chunks.py +36 -0
  40. package/backend/app/routers/entity_extract.py +31 -0
  41. package/backend/app/routers/example.py +8 -0
  42. package/backend/app/routers/gemini_embed.py +58 -0
  43. package/backend/app/routers/health.py +28 -0
  44. package/backend/app/routers/ingestion.py +146 -0
  45. package/backend/app/routers/link_expansion.py +34 -0
  46. package/backend/app/routers/pipeline_status.py +304 -0
  47. package/backend/app/routers/query.py +63 -0
  48. package/backend/app/routers/vectors.py +63 -0
  49. package/backend/app/schemas/__init__.py +0 -0
  50. package/backend/app/schemas/canonical.py +44 -0
  51. package/backend/app/schemas/chat.py +50 -0
  52. package/backend/app/schemas/ingest.py +29 -0
  53. package/backend/app/schemas/query.py +153 -0
  54. package/backend/app/schemas/vectors.py +56 -0
  55. package/backend/app/services/__init__.py +0 -0
  56. package/backend/app/services/chat_store.py +152 -0
  57. package/backend/app/services/chunking/__init__.py +3 -0
  58. package/backend/app/services/chunking/llm_boundaries.py +63 -0
  59. package/backend/app/services/chunking/schemas.py +30 -0
  60. package/backend/app/services/chunking/semantic_chunk.py +178 -0
  61. package/backend/app/services/chunking/splitters.py +214 -0
  62. package/backend/app/services/embeddings/__init__.py +20 -0
  63. package/backend/app/services/embeddings/build_inputs.py +140 -0
  64. package/backend/app/services/embeddings/dlq.py +128 -0
  65. package/backend/app/services/embeddings/gemini_api.py +207 -0
  66. package/backend/app/services/embeddings/persist.py +74 -0
  67. package/backend/app/services/embeddings/types.py +32 -0
  68. package/backend/app/services/embeddings/worker.py +224 -0
  69. package/backend/app/services/entities/__init__.py +12 -0
  70. package/backend/app/services/entities/gliner_extract.py +63 -0
  71. package/backend/app/services/entities/llm_extract.py +94 -0
  72. package/backend/app/services/entities/pipeline.py +179 -0
  73. package/backend/app/services/entities/spacy_extract.py +63 -0
  74. package/backend/app/services/entities/types.py +15 -0
  75. package/backend/app/services/gemini_chat.py +113 -0
  76. package/backend/app/services/hooks/__init__.py +3 -0
  77. package/backend/app/services/hooks/post_ingest.py +186 -0
  78. package/backend/app/services/ingestion/__init__.py +0 -0
  79. package/backend/app/services/ingestion/persist.py +188 -0
  80. package/backend/app/services/integrations_remote.py +91 -0
  81. package/backend/app/services/link_expansion/__init__.py +3 -0
  82. package/backend/app/services/link_expansion/canonical_url.py +45 -0
  83. package/backend/app/services/link_expansion/domain_policy.py +26 -0
  84. package/backend/app/services/link_expansion/html_extract.py +72 -0
  85. package/backend/app/services/link_expansion/rate_limit.py +32 -0
  86. package/backend/app/services/link_expansion/robots.py +46 -0
  87. package/backend/app/services/link_expansion/schemas.py +67 -0
  88. package/backend/app/services/link_expansion/worker.py +458 -0
  89. package/backend/app/services/normalization/__init__.py +7 -0
  90. package/backend/app/services/normalization/normalizer.py +331 -0
  91. package/backend/app/services/normalization/persist_normalized.py +67 -0
  92. package/backend/app/services/playwright_extract/__init__.py +13 -0
  93. package/backend/app/services/playwright_extract/__main__.py +96 -0
  94. package/backend/app/services/playwright_extract/extract.py +181 -0
  95. package/backend/app/services/retrieval_service.py +351 -0
  96. package/backend/app/sqlite_ext.py +36 -0
  97. package/backend/app/storage/__init__.py +3 -0
  98. package/backend/app/storage/blobs.py +30 -0
  99. package/backend/app/vectorstore/__init__.py +13 -0
  100. package/backend/app/vectorstore/sqlite_vec_store.py +242 -0
  101. package/backend/backend.egg-info/PKG-INFO +18 -0
  102. package/backend/backend.egg-info/SOURCES.txt +93 -0
  103. package/backend/backend.egg-info/dependency_links.txt +1 -0
  104. package/backend/backend.egg-info/entry_points.txt +2 -0
  105. package/backend/backend.egg-info/requires.txt +15 -0
  106. package/backend/backend.egg-info/top_level.txt +4 -0
  107. package/backend/package.json +15 -0
  108. package/backend/pyproject.toml +52 -0
  109. package/backend/tests/conftest.py +40 -0
  110. package/backend/tests/test_chat.py +92 -0
  111. package/backend/tests/test_chunking.py +132 -0
  112. package/backend/tests/test_entities.py +170 -0
  113. package/backend/tests/test_gemini_embed.py +224 -0
  114. package/backend/tests/test_health.py +24 -0
  115. package/backend/tests/test_ingest_raw.py +123 -0
  116. package/backend/tests/test_link_expansion.py +241 -0
  117. package/backend/tests/test_main.py +12 -0
  118. package/backend/tests/test_normalizer.py +114 -0
  119. package/backend/tests/test_openapi_gateway.py +40 -0
  120. package/backend/tests/test_pipeline_hardening.py +285 -0
  121. package/backend/tests/test_pipeline_status.py +71 -0
  122. package/backend/tests/test_playwright_extract.py +80 -0
  123. package/backend/tests/test_post_ingest_hooks.py +162 -0
  124. package/backend/tests/test_query.py +165 -0
  125. package/backend/tests/test_thread_expansion.py +72 -0
  126. package/backend/tests/test_vectors.py +85 -0
  127. package/backend/uv.lock +1839 -0
  128. package/bin/business-stack.cjs +412 -0
  129. package/frontend/web/.env.example +23 -0
  130. package/frontend/web/AGENTS.md +5 -0
  131. package/frontend/web/CLAUDE.md +1 -0
  132. package/frontend/web/README.md +36 -0
  133. package/frontend/web/components.json +25 -0
  134. package/frontend/web/next-env.d.ts +6 -0
  135. package/frontend/web/next.config.ts +30 -0
  136. package/frontend/web/package.json +65 -0
  137. package/frontend/web/postcss.config.mjs +7 -0
  138. package/frontend/web/skills-lock.json +35 -0
  139. package/frontend/web/src/app/account/[[...path]]/page.tsx +19 -0
  140. package/frontend/web/src/app/auth/[[...path]]/page.tsx +14 -0
  141. package/frontend/web/src/app/chat/page.tsx +725 -0
  142. package/frontend/web/src/app/favicon.ico +0 -0
  143. package/frontend/web/src/app/globals.css +563 -0
  144. package/frontend/web/src/app/layout.tsx +50 -0
  145. package/frontend/web/src/app/page.tsx +96 -0
  146. package/frontend/web/src/app/settings/integrations/actions.ts +74 -0
  147. package/frontend/web/src/app/settings/integrations/integrations-settings-form.tsx +330 -0
  148. package/frontend/web/src/app/settings/integrations/page.tsx +41 -0
  149. package/frontend/web/src/app/webhooks/alpha-alerts/route.ts +84 -0
  150. package/frontend/web/src/components/home-auth-panel.tsx +49 -0
  151. package/frontend/web/src/components/providers.tsx +50 -0
  152. package/frontend/web/src/lib/alpha-webhook/connectors/registry.ts +35 -0
  153. package/frontend/web/src/lib/alpha-webhook/connectors/types.ts +8 -0
  154. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.test.ts +40 -0
  155. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge-delivery.ts +78 -0
  156. package/frontend/web/src/lib/alpha-webhook/connectors/wabridge.ts +30 -0
  157. package/frontend/web/src/lib/alpha-webhook/handler.ts +12 -0
  158. package/frontend/web/src/lib/alpha-webhook/signature.test.ts +33 -0
  159. package/frontend/web/src/lib/alpha-webhook/signature.ts +21 -0
  160. package/frontend/web/src/lib/alpha-webhook/types.ts +23 -0
  161. package/frontend/web/src/lib/auth-client.ts +23 -0
  162. package/frontend/web/src/lib/integrations-config.ts +125 -0
  163. package/frontend/web/src/lib/ui-utills.tsx +90 -0
  164. package/frontend/web/src/lib/utils.ts +6 -0
  165. package/frontend/web/tsconfig.json +36 -0
  166. package/frontend/web/tsconfig.tsbuildinfo +1 -0
  167. package/frontend/web/vitest.config.ts +14 -0
  168. package/gateway/.env.example +23 -0
  169. package/gateway/README.md +13 -0
  170. package/gateway/package.json +24 -0
  171. package/gateway/src/auth.ts +49 -0
  172. package/gateway/src/index.ts +141 -0
  173. package/gateway/src/integrations/admin.ts +19 -0
  174. package/gateway/src/integrations/crypto.ts +52 -0
  175. package/gateway/src/integrations/handlers.ts +124 -0
  176. package/gateway/src/integrations/keys.ts +12 -0
  177. package/gateway/src/integrations/store.ts +106 -0
  178. package/gateway/src/stack-secrets.ts +35 -0
  179. package/gateway/tsconfig.json +13 -0
  180. package/package.json +33 -0
  181. package/turbo.json +27 -0
@@ -0,0 +1,14 @@
1
+ import path from "node:path";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: "node",
7
+ include: ["src/**/*.test.ts"],
8
+ },
9
+ resolve: {
10
+ alias: {
11
+ "@": path.resolve(__dirname, "./src"),
12
+ },
13
+ },
14
+ });
@@ -0,0 +1,23 @@
1
+ # Required for OAuth callbacks / redirects. Use the URL the *browser* uses (Next app), not the gateway port.
2
+ # Default in code is http://localhost:3000 if unset.
3
+ BETTER_AUTH_URL=http://localhost:3000
4
+
5
+ # Production: openssl rand -base64 32
6
+ # BETTER_AUTH_SECRET=
7
+
8
+ # Optional: comma-separated origins for CSRF (defaults include localhost:3000)
9
+ # BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
10
+
11
+ # Same value as backend BACKEND_GATEWAY_SECRET. When set, OpenAPI UIs work only when
12
+ # proxied through this gateway (backend returns 403 for /docs without this header).
13
+ # BACKEND_GATEWAY_SECRET=
14
+
15
+ # Encrypted integration settings (Alpha webhook, WABridge, Gemini API key for FastAPI) in the Better Auth SQLite DB.
16
+ # openssl rand -hex 32
17
+ # INTEGRATIONS_ENCRYPTION_KEY=
18
+ # Shared with Next + FastAPI: GET /internal/integrations with Authorization: Bearer ...
19
+ # openssl rand -base64 32
20
+ # INTEGRATIONS_INTERNAL_SECRET=
21
+ # Optional: comma-separated integration admin emails. If unset, any signed-in user may read plaintext secrets and PUT settings.
22
+ # Admins receive decrypted values from GET /api/integrations/settings; other signed-in users see masked secrets only.
23
+ # INTEGRATION_SETTINGS_ADMIN_EMAILS=admin@example.com
@@ -0,0 +1,13 @@
1
+ To install dependencies:
2
+ ```sh
3
+ bun install
4
+ ```
5
+
6
+ To run:
7
+ ```sh
8
+ bun run dev
9
+ ```
10
+
11
+ The gateway listens on **port 3001** by default (`PORT`). Open `http://127.0.0.1:3001` (or set `PORT`).
12
+
13
+ Better Auth needs a public **base URL** (where `/api/auth` is reached in the browser). With the Next.js app proxying auth, set `BETTER_AUTH_URL` to that origin (e.g. `http://localhost:3000`). See `.env.example`. If unset, the gateway defaults to `http://localhost:3000`.
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@business-stack/gateway",
3
+ "private": true,
4
+ "packageManager": "bun@1.3.1",
5
+ "scripts": {
6
+ "dev": "bun run --hot src/index.ts",
7
+ "start": "bun run src/index.ts",
8
+ "migrate": "bun x @better-auth/cli@latest migrate --yes",
9
+ "build": "bun -e \"require('fs').mkdirSync('dist',{recursive:true}); require('fs').writeFileSync('dist/.buildstamp','')\"",
10
+ "lint": "biome check .",
11
+ "lint:fix": "biome check --write .",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "bun -e \"process.exit(0)\"",
14
+ "clean": "bun x rimraf@6 dist"
15
+ },
16
+ "dependencies": {
17
+ "better-auth": "1.5.6",
18
+ "hono": "^4.12.10"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "latest",
22
+ "typescript": "^5.8.3"
23
+ }
24
+ }
@@ -0,0 +1,49 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { betterAuth } from "better-auth";
3
+ import { loadStackSecretsIntoEnv } from "./stack-secrets";
4
+
5
+ loadStackSecretsIntoEnv();
6
+
7
+ const dbPath = process.env.BETTER_AUTH_DATABASE_PATH ?? "auth.sqlite";
8
+ const sqlite = new Database(dbPath, { create: true });
9
+
10
+ function parseTrustedOrigins(): string[] {
11
+ const raw = process.env.BETTER_AUTH_TRUSTED_ORIGINS;
12
+ if (raw?.trim()) {
13
+ return raw
14
+ .split(",")
15
+ .map((o) => o.trim())
16
+ .filter(Boolean);
17
+ }
18
+ return ["http://localhost:3000", "http://127.0.0.1:3000"];
19
+ }
20
+
21
+ /** Public origin where clients call /api/auth (Next dev server); gateway is proxied behind it. */
22
+ const baseURL =
23
+ process.env.BETTER_AUTH_URL?.replace(/\/$/, "") || "http://localhost:3000";
24
+
25
+ export const auth = betterAuth({
26
+ baseURL,
27
+ database: sqlite,
28
+ emailAndPassword: { enabled: true },
29
+ rateLimit: {
30
+ enabled: true,
31
+ storage: "database",
32
+ },
33
+ session: {
34
+ cookieCache: {
35
+ enabled: true,
36
+ maxAge: 60 * 5,
37
+ strategy: "compact",
38
+ },
39
+ },
40
+ account: {
41
+ encryptOAuthTokens: true,
42
+ },
43
+ trustedOrigins: parseTrustedOrigins(),
44
+ advanced: {
45
+ ipAddress: {
46
+ ipAddressHeaders: ["x-forwarded-for", "x-real-ip"],
47
+ },
48
+ },
49
+ });
@@ -0,0 +1,141 @@
1
+ import { Hono } from "hono";
2
+ import { cors } from "hono/cors";
3
+ import { proxy } from "hono/proxy";
4
+ import { auth } from "./auth";
5
+ import {
6
+ handleGetIntegrationSettings,
7
+ handleInternalIntegrationsGet,
8
+ handlePutIntegrationSettings,
9
+ } from "./integrations/handlers";
10
+
11
+ type Variables = {
12
+ user: typeof auth.$Infer.Session.user | null;
13
+ session: typeof auth.$Infer.Session.session | null;
14
+ };
15
+
16
+ const corsOrigin = process.env.CORS_ORIGIN?.trim() || "http://localhost:3000";
17
+
18
+ const backendOrigin = (
19
+ process.env.BACKEND_ORIGIN?.trim() || "http://127.0.0.1:8000"
20
+ ).replace(/\/$/, "");
21
+
22
+ const apiGatewayPrefix =
23
+ process.env.API_GATEWAY_PREFIX?.trim() || "/api/backend";
24
+
25
+ const app = new Hono<{ Variables: Variables }>();
26
+
27
+ app.use(
28
+ "*",
29
+ cors({
30
+ origin: corsOrigin,
31
+ allowHeaders: ["Content-Type", "Authorization"],
32
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
33
+ exposeHeaders: ["Content-Length"],
34
+ maxAge: 600,
35
+ credentials: true,
36
+ }),
37
+ );
38
+
39
+ app.use("*", async (c, next) => {
40
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
41
+
42
+ if (!session) {
43
+ c.set("user", null);
44
+ c.set("session", null);
45
+ await next();
46
+ return;
47
+ }
48
+
49
+ c.set("user", session.user);
50
+ c.set("session", session.session);
51
+ await next();
52
+ });
53
+
54
+ app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
55
+
56
+ app.get("/internal/integrations", handleInternalIntegrationsGet);
57
+
58
+ const integrationsApi = new Hono<{ Variables: Variables }>();
59
+ integrationsApi.get("/settings", handleGetIntegrationSettings);
60
+ integrationsApi.put("/settings", handlePutIntegrationSettings);
61
+ app.route("/api/integrations", integrationsApi);
62
+
63
+ const backendProxy = new Hono<{ Variables: Variables }>();
64
+
65
+ backendProxy.all("*", async (c) => {
66
+ const user = c.get("user");
67
+ if (!user) {
68
+ return c.json({ error: "Unauthorized" }, 401);
69
+ }
70
+
71
+ const pathname = new URL(c.req.url).pathname;
72
+ const prefixNorm = apiGatewayPrefix.replace(/\/$/, "");
73
+ let path: string;
74
+ if (pathname === prefixNorm || pathname === `${prefixNorm}/`) {
75
+ path = "/";
76
+ } else if (pathname.startsWith(`${prefixNorm}/`)) {
77
+ path = pathname.slice(prefixNorm.length);
78
+ } else {
79
+ path = pathname;
80
+ }
81
+ if (!path || path === "") {
82
+ path = "/";
83
+ }
84
+ if (!path.startsWith("/")) {
85
+ path = `/${path}`;
86
+ }
87
+
88
+ const upstream = new URL(backendOrigin);
89
+ upstream.pathname = path;
90
+ upstream.search = new URL(c.req.url).search;
91
+
92
+ const proto = new URL(c.req.url).protocol.replace(":", "");
93
+ const host = c.req.header("host") ?? "";
94
+ const forwardedFor = c.req.header("x-forwarded-for");
95
+ const gatewaySecret = process.env.BACKEND_GATEWAY_SECRET?.trim();
96
+
97
+ return proxy(upstream.toString(), {
98
+ ...c.req,
99
+ headers: {
100
+ ...c.req.header(),
101
+ ...(forwardedFor ? { "X-Forwarded-For": forwardedFor } : {}),
102
+ "X-Forwarded-Host": host,
103
+ "X-Forwarded-Proto": proto,
104
+ // FastAPI Swagger/ReDoc use this as ASGI root_path so /openapi.json matches the gateway prefix.
105
+ "X-Forwarded-Prefix": prefixNorm,
106
+ ...(gatewaySecret ? { "X-Gateway-Secret": gatewaySecret } : {}),
107
+ "X-User-Id": user.id,
108
+ ...(user.email != null && user.email !== ""
109
+ ? { "X-User-Email": user.email }
110
+ : {}),
111
+ },
112
+ });
113
+ });
114
+
115
+ app.route(apiGatewayPrefix, backendProxy);
116
+
117
+ app.get("/", (c) => {
118
+ return c.text("Hello Hono!");
119
+ });
120
+
121
+ app.get("/session", (c) => {
122
+ const session = c.get("session");
123
+ const user = c.get("user");
124
+
125
+ if (!user) {
126
+ return c.body(null, 401);
127
+ }
128
+
129
+ return c.json({ session, user });
130
+ });
131
+
132
+ const port = Number(process.env.PORT ?? 3001);
133
+
134
+ export default {
135
+ port,
136
+ fetch: app.fetch,
137
+ };
138
+
139
+ console.log(
140
+ `Gateway listening on http://localhost:${port} (backend ${backendOrigin}, prefix ${apiGatewayPrefix})`,
141
+ );
@@ -0,0 +1,19 @@
1
+ /** Returns true if this user may change integration settings. */
2
+ export function isIntegrationSettingsAdmin(
3
+ email: string | null | undefined,
4
+ ): boolean {
5
+ const raw = process.env.INTEGRATION_SETTINGS_ADMIN_EMAILS?.trim();
6
+ if (!raw) {
7
+ return true;
8
+ }
9
+ if (!email?.trim()) {
10
+ return false;
11
+ }
12
+ const allow = new Set(
13
+ raw
14
+ .split(",")
15
+ .map((e) => e.trim().toLowerCase())
16
+ .filter(Boolean),
17
+ );
18
+ return allow.has(email.trim().toLowerCase());
19
+ }
@@ -0,0 +1,52 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ } from "node:crypto";
7
+
8
+ const ALGO = "aes-256-gcm";
9
+ const IV_LENGTH = 12;
10
+ const AUTH_TAG_LENGTH = 16;
11
+
12
+ function integrationKeyMaterial(): Buffer {
13
+ const raw = process.env.INTEGRATIONS_ENCRYPTION_KEY?.trim();
14
+ if (!raw) {
15
+ throw new Error("INTEGRATIONS_ENCRYPTION_KEY is not set");
16
+ }
17
+ if (/^[0-9a-fA-F]{64}$/.test(raw)) {
18
+ return Buffer.from(raw, "hex");
19
+ }
20
+ return createHash("sha256").update(raw, "utf8").digest();
21
+ }
22
+
23
+ /** Returns base64(iv || ciphertext+tag) for storage. */
24
+ export function encryptIntegrationValue(plaintext: string): string {
25
+ const key = integrationKeyMaterial();
26
+ const iv = randomBytes(IV_LENGTH);
27
+ const cipher = createCipheriv(ALGO, key, iv, {
28
+ authTagLength: AUTH_TAG_LENGTH,
29
+ });
30
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
31
+ const tag = cipher.getAuthTag();
32
+ const combined = Buffer.concat([iv, tag, enc]);
33
+ return combined.toString("base64");
34
+ }
35
+
36
+ export function decryptIntegrationValue(payloadB64: string): string {
37
+ const key = integrationKeyMaterial();
38
+ const buf = Buffer.from(payloadB64, "base64");
39
+ if (buf.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) {
40
+ throw new Error("invalid ciphertext");
41
+ }
42
+ const iv = buf.subarray(0, IV_LENGTH);
43
+ const tag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
44
+ const data = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
45
+ const decipher = createDecipheriv(ALGO, key, iv, {
46
+ authTagLength: AUTH_TAG_LENGTH,
47
+ });
48
+ decipher.setAuthTag(tag);
49
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString(
50
+ "utf8",
51
+ );
52
+ }
@@ -0,0 +1,124 @@
1
+ import type { Context } from "hono";
2
+ import type { auth } from "../auth";
3
+ import { isIntegrationSettingsAdmin } from "./admin";
4
+ import { readFullSnapshot, upsertFromBody } from "./store";
5
+
6
+ type Variables = {
7
+ user: typeof auth.$Infer.Session.user | null;
8
+ session: typeof auth.$Infer.Session.session | null;
9
+ };
10
+
11
+ function maskIfSet(isSet: boolean): string | null {
12
+ return isSet ? "********" : null;
13
+ }
14
+
15
+ /** GET /api/integrations/settings — session required; secrets plaintext for integration admins only. */
16
+ export async function handleGetIntegrationSettings(
17
+ c: Context<{ Variables: Variables }>,
18
+ ) {
19
+ const user = c.get("user");
20
+ if (!user) {
21
+ return c.json({ error: "Unauthorized" }, 401);
22
+ }
23
+
24
+ const snap = readFullSnapshot();
25
+ const reveal = isIntegrationSettingsAdmin(user.email);
26
+
27
+ return c.json({
28
+ alphaWebhookSecret: reveal
29
+ ? snap.alphaWebhookSecret && snap.alphaWebhookSecret !== ""
30
+ ? snap.alphaWebhookSecret
31
+ : null
32
+ : maskIfSet(
33
+ snap.alphaWebhookSecret != null && snap.alphaWebhookSecret !== "",
34
+ ),
35
+ wabridgeBaseUrl: snap.wabridgeBaseUrl ?? null,
36
+ wabridgePhone: reveal
37
+ ? snap.wabridgePhone && snap.wabridgePhone !== ""
38
+ ? snap.wabridgePhone
39
+ : null
40
+ : maskIfSet(snap.wabridgePhone != null && snap.wabridgePhone !== ""),
41
+ wabridgeEnabled:
42
+ snap.wabridgeEnabled === "true"
43
+ ? true
44
+ : snap.wabridgeEnabled === "false"
45
+ ? false
46
+ : null,
47
+ geminiApiKey: reveal
48
+ ? snap.geminiApiKey && snap.geminiApiKey !== ""
49
+ ? snap.geminiApiKey
50
+ : null
51
+ : maskIfSet(snap.geminiApiKey != null && snap.geminiApiKey !== ""),
52
+ });
53
+ }
54
+
55
+ /** PUT /api/integrations/settings — session + admin allowlist. */
56
+ export async function handlePutIntegrationSettings(
57
+ c: Context<{ Variables: Variables }>,
58
+ ) {
59
+ const user = c.get("user");
60
+ if (!user) {
61
+ return c.json({ error: "Unauthorized" }, 401);
62
+ }
63
+ if (!isIntegrationSettingsAdmin(user.email)) {
64
+ return c.json({ error: "Forbidden" }, 403);
65
+ }
66
+
67
+ if (!process.env.INTEGRATIONS_ENCRYPTION_KEY?.trim()) {
68
+ return c.json(
69
+ { error: "INTEGRATIONS_ENCRYPTION_KEY is not configured on the gateway" },
70
+ 503,
71
+ );
72
+ }
73
+
74
+ let body: Record<string, unknown>;
75
+ try {
76
+ body = (await c.req.json()) as Record<string, unknown>;
77
+ } catch {
78
+ return c.json({ error: "Invalid JSON" }, 400);
79
+ }
80
+
81
+ try {
82
+ upsertFromBody(body);
83
+ } catch (e) {
84
+ const msg = e instanceof Error ? e.message : "update failed";
85
+ return c.json({ error: msg }, 400);
86
+ }
87
+
88
+ return c.json({ ok: true });
89
+ }
90
+
91
+ /** GET /internal/integrations — Bearer INTEGRATIONS_INTERNAL_SECRET; plaintext for Next server. */
92
+ export async function handleInternalIntegrationsGet(c: Context) {
93
+ const expected = process.env.INTEGRATIONS_INTERNAL_SECRET?.trim();
94
+ if (!expected) {
95
+ return c.json(
96
+ { error: "INTEGRATIONS_INTERNAL_SECRET is not configured" },
97
+ 503,
98
+ );
99
+ }
100
+
101
+ const authz = c.req.header("authorization")?.trim() ?? "";
102
+ const token = authz.toLowerCase().startsWith("bearer ")
103
+ ? authz.slice(7).trim()
104
+ : null;
105
+ if (!token || token !== expected) {
106
+ return c.json({ error: "Unauthorized" }, 401);
107
+ }
108
+
109
+ if (!process.env.INTEGRATIONS_ENCRYPTION_KEY?.trim()) {
110
+ return c.json(
111
+ { error: "INTEGRATIONS_ENCRYPTION_KEY is not configured" },
112
+ 503,
113
+ );
114
+ }
115
+
116
+ const snap = readFullSnapshot();
117
+ return c.json({
118
+ alphaWebhookSecret: snap.alphaWebhookSecret,
119
+ wabridgeBaseUrl: snap.wabridgeBaseUrl,
120
+ wabridgePhone: snap.wabridgePhone,
121
+ wabridgeEnabled: snap.wabridgeEnabled,
122
+ geminiApiKey: snap.geminiApiKey,
123
+ });
124
+ }
@@ -0,0 +1,12 @@
1
+ /** SQLite row keys (stable identifiers). */
2
+ export const INTEGRATION_DB_KEYS = {
3
+ alphaWebhookSecret: "alpha_webhook_secret",
4
+ wabridgeBaseUrl: "wabridge_base_url",
5
+ wabridgePhone: "wabridge_phone",
6
+ wabridgeEnabled: "wabridge_enabled",
7
+ /** Used by the FastAPI backend (embed + /query) when GEMINI_API_KEY is unset. */
8
+ geminiApiKey: "gemini_api_key",
9
+ } as const;
10
+
11
+ export type IntegrationDbKey =
12
+ (typeof INTEGRATION_DB_KEYS)[keyof typeof INTEGRATION_DB_KEYS];
@@ -0,0 +1,106 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { decryptIntegrationValue, encryptIntegrationValue } from "./crypto";
3
+ import { INTEGRATION_DB_KEYS, type IntegrationDbKey } from "./keys";
4
+
5
+ const dbPath = process.env.BETTER_AUTH_DATABASE_PATH ?? "auth.sqlite";
6
+
7
+ let _db: Database | null = null;
8
+
9
+ function getDb(): Database {
10
+ if (!_db) {
11
+ _db = new Database(dbPath, { create: true });
12
+ _db.run(`
13
+ CREATE TABLE IF NOT EXISTS integration_settings (
14
+ key TEXT PRIMARY KEY NOT NULL,
15
+ ciphertext TEXT NOT NULL,
16
+ updated_at TEXT NOT NULL
17
+ )
18
+ `);
19
+ }
20
+ return _db;
21
+ }
22
+
23
+ export function getEncryptedRow(key: IntegrationDbKey): string | null {
24
+ const row = getDb()
25
+ .query("SELECT ciphertext FROM integration_settings WHERE key = ?")
26
+ .get(key) as { ciphertext: string } | null;
27
+ return row?.ciphertext ?? null;
28
+ }
29
+
30
+ export function setEncryptedRow(
31
+ key: IntegrationDbKey,
32
+ plaintext: string,
33
+ ): void {
34
+ const ciphertext = encryptIntegrationValue(plaintext);
35
+ const updatedAt = new Date().toISOString();
36
+ getDb().run(
37
+ `INSERT INTO integration_settings (key, ciphertext, updated_at)
38
+ VALUES (?, ?, ?)
39
+ ON CONFLICT(key) DO UPDATE SET ciphertext = excluded.ciphertext, updated_at = excluded.updated_at`,
40
+ [key, ciphertext, updatedAt],
41
+ );
42
+ }
43
+
44
+ export function deleteRow(key: IntegrationDbKey): void {
45
+ getDb().run("DELETE FROM integration_settings WHERE key = ?", [key]);
46
+ }
47
+
48
+ export function getPlaintext(key: IntegrationDbKey): string | null {
49
+ const row = getEncryptedRow(key);
50
+ if (!row) return null;
51
+ try {
52
+ return decryptIntegrationValue(row);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export type IntegrationSnapshot = {
59
+ alphaWebhookSecret: string | null;
60
+ wabridgeBaseUrl: string | null;
61
+ wabridgePhone: string | null;
62
+ wabridgeEnabled: string | null;
63
+ geminiApiKey: string | null;
64
+ };
65
+
66
+ export function readFullSnapshot(): IntegrationSnapshot {
67
+ return {
68
+ alphaWebhookSecret: getPlaintext(INTEGRATION_DB_KEYS.alphaWebhookSecret),
69
+ wabridgeBaseUrl: getPlaintext(INTEGRATION_DB_KEYS.wabridgeBaseUrl),
70
+ wabridgePhone: getPlaintext(INTEGRATION_DB_KEYS.wabridgePhone),
71
+ wabridgeEnabled: getPlaintext(INTEGRATION_DB_KEYS.wabridgeEnabled),
72
+ geminiApiKey: getPlaintext(INTEGRATION_DB_KEYS.geminiApiKey),
73
+ };
74
+ }
75
+
76
+ export function upsertFromBody(body: Record<string, unknown>): void {
77
+ const setIfString = (k: IntegrationDbKey, v: unknown) => {
78
+ if (v === undefined) return;
79
+ if (v === null || v === "") {
80
+ deleteRow(k);
81
+ return;
82
+ }
83
+ if (typeof v !== "string") {
84
+ throw new Error(`invalid type for ${k}`);
85
+ }
86
+ setEncryptedRow(k, v);
87
+ };
88
+
89
+ const setBool = (k: IntegrationDbKey, v: unknown) => {
90
+ if (v === undefined) return;
91
+ if (v === null) {
92
+ deleteRow(k);
93
+ return;
94
+ }
95
+ if (typeof v !== "boolean") {
96
+ throw new Error(`invalid type for ${k}`);
97
+ }
98
+ setEncryptedRow(k, v ? "true" : "false");
99
+ };
100
+
101
+ setIfString(INTEGRATION_DB_KEYS.alphaWebhookSecret, body.alphaWebhookSecret);
102
+ setIfString(INTEGRATION_DB_KEYS.wabridgeBaseUrl, body.wabridgeBaseUrl);
103
+ setIfString(INTEGRATION_DB_KEYS.wabridgePhone, body.wabridgePhone);
104
+ setBool(INTEGRATION_DB_KEYS.wabridgeEnabled, body.wabridgeEnabled);
105
+ setIfString(INTEGRATION_DB_KEYS.geminiApiKey, body.geminiApiKey);
106
+ }
@@ -0,0 +1,35 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ /**
4
+ * If an env var is unset or whitespace-only, fill it from `stack_secrets` in the auth DB.
5
+ * Call before Better Auth and integration crypto read secrets from `process.env`.
6
+ */
7
+ export function loadStackSecretsIntoEnv(): void {
8
+ const dbPath = process.env.BETTER_AUTH_DATABASE_PATH ?? "auth.sqlite";
9
+ let db: Database;
10
+ try {
11
+ db = new Database(dbPath, { create: true });
12
+ } catch {
13
+ return;
14
+ }
15
+ try {
16
+ db.run(`
17
+ CREATE TABLE IF NOT EXISTS stack_secrets (
18
+ key TEXT PRIMARY KEY NOT NULL,
19
+ value TEXT NOT NULL
20
+ )
21
+ `);
22
+ const rows = db.query("SELECT key, value FROM stack_secrets").all() as {
23
+ key: string;
24
+ value: string;
25
+ }[];
26
+ for (const { key, value } of rows) {
27
+ const cur = process.env[key]?.trim();
28
+ if (!cur && value.trim()) {
29
+ process.env[key] = value;
30
+ }
31
+ }
32
+ } finally {
33
+ db.close();
34
+ }
35
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "target": "ES2022",
7
+ "lib": ["ES2022"],
8
+ "types": ["bun-types"],
9
+ "skipLibCheck": true,
10
+ "noEmit": true
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "business-stack",
3
+ "version": "0.1.0",
4
+ "description": "Next.js + Hono gateway + FastAPI monorepo",
5
+ "license": "UNLICENSED",
6
+ "packageManager": "bun@1.3.1",
7
+ "workspaces": ["frontend/web", "gateway", "backend"],
8
+ "scripts": {
9
+ "prepublishOnly": "node ../scripts/sync-npm-package.cjs",
10
+ "dev": "turbo dev --filter=@business-stack/web --filter=@business-stack/gateway --filter=@business-stack/backend",
11
+ "start": "turbo run start --filter=@business-stack/web --filter=@business-stack/gateway --filter=@business-stack/backend",
12
+ "build": "turbo run build --filter=@business-stack/web --filter=@business-stack/gateway --filter=@business-stack/backend"
13
+ },
14
+ "bin": {
15
+ "business-stack": "bin/business-stack.cjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "turbo.json",
20
+ ".python-version",
21
+ "frontend/web",
22
+ "gateway",
23
+ "backend"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "dependencies": {
29
+ "better-sqlite3": "^11.7.0",
30
+ "commander": "^14.0.0",
31
+ "turbo": "^2.5.4"
32
+ }
33
+ }