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,74 @@
1
+ "use server";
2
+
3
+ import { headers } from "next/headers";
4
+ import { invalidateIntegrationsConfigCache } from "@/lib/integrations-config";
5
+
6
+ const gateway =
7
+ process.env.AUTH_GATEWAY_INTERNAL_URL?.trim() || "http://127.0.0.1:3001";
8
+
9
+ export type IntegrationSettingsView = {
10
+ alphaWebhookSecret: string | null;
11
+ wabridgeBaseUrl: string | null;
12
+ wabridgePhone: string | null;
13
+ wabridgeEnabled: boolean | null;
14
+ geminiApiKey: string | null;
15
+ };
16
+
17
+ export async function loadIntegrationSettingsForForm(): Promise<
18
+ | { ok: true; data: IntegrationSettingsView }
19
+ | { ok: false; reason: "unauthorized" | "error" }
20
+ > {
21
+ const cookie = (await headers()).get("cookie") ?? "";
22
+ const res = await fetch(`${gateway}/api/integrations/settings`, {
23
+ headers: { cookie },
24
+ cache: "no-store",
25
+ });
26
+ if (res.status === 401) {
27
+ return { ok: false, reason: "unauthorized" };
28
+ }
29
+ if (!res.ok) {
30
+ return { ok: false, reason: "error" };
31
+ }
32
+ const data = (await res.json()) as IntegrationSettingsView;
33
+ return { ok: true, data };
34
+ }
35
+
36
+ export type SaveIntegrationPayload = {
37
+ alphaWebhookSecret?: string | null;
38
+ wabridgeBaseUrl?: string | null;
39
+ wabridgePhone?: string | null;
40
+ wabridgeEnabled?: boolean | null;
41
+ geminiApiKey?: string | null;
42
+ };
43
+
44
+ export async function saveIntegrationSettings(
45
+ payload: SaveIntegrationPayload,
46
+ ): Promise<{ ok: boolean; error?: string }> {
47
+ const cookie = (await headers()).get("cookie") ?? "";
48
+ const res = await fetch(`${gateway}/api/integrations/settings`, {
49
+ method: "PUT",
50
+ headers: { "Content-Type": "application/json", cookie },
51
+ body: JSON.stringify(payload),
52
+ cache: "no-store",
53
+ });
54
+ if (res.status === 401) {
55
+ return { ok: false, error: "Unauthorized" };
56
+ }
57
+ if (res.status === 403) {
58
+ return {
59
+ ok: false,
60
+ error:
61
+ "Forbidden — your email may not be in INTEGRATION_SETTINGS_ADMIN_EMAILS",
62
+ };
63
+ }
64
+ if (!res.ok) {
65
+ const j = (await res.json().catch(() => null)) as {
66
+ error?: unknown;
67
+ } | null;
68
+ const err =
69
+ j?.error != null && typeof j.error === "string" ? j.error : "Save failed";
70
+ return { ok: false, error: err };
71
+ }
72
+ invalidateIntegrationsConfigCache();
73
+ return { ok: true };
74
+ }
@@ -0,0 +1,330 @@
1
+ "use client";
2
+
3
+ import { Eye, EyeOff } from "lucide-react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useEffect, useState, useTransition } from "react";
6
+ import {
7
+ type IntegrationSettingsView,
8
+ saveIntegrationSettings,
9
+ } from "./actions";
10
+
11
+ /** Must match gateway masked response for set secrets */
12
+ const MASK = "********";
13
+
14
+ function isMaskedSecret(v: string | null): boolean {
15
+ return v === MASK;
16
+ }
17
+
18
+ /**
19
+ * How to update a secret field on save.
20
+ * - undefined: omit from payload (unchanged)
21
+ * - null: remove stored value
22
+ * - string: new value
23
+ *
24
+ * `initialFromServer` is either plaintext (integration admin) or the mask token when the
25
+ * gateway still hides the value.
26
+ */
27
+ function secretFieldUpdate(
28
+ raw: string,
29
+ initialFromServer: string | null,
30
+ ): string | null | undefined {
31
+ const t = raw.trim();
32
+ if (isMaskedSecret(initialFromServer)) {
33
+ if (t === MASK) {
34
+ return undefined;
35
+ }
36
+ if (t === "") {
37
+ return null;
38
+ }
39
+ return t;
40
+ }
41
+
42
+ const initialT = (initialFromServer ?? "").trim();
43
+ const hadDbValue = initialT !== "";
44
+ if (t === initialT) {
45
+ return undefined;
46
+ }
47
+ if (t === "") {
48
+ return hadDbValue ? null : undefined;
49
+ }
50
+ return t;
51
+ }
52
+
53
+ type Props = {
54
+ initial: IntegrationSettingsView;
55
+ };
56
+
57
+ export function IntegrationsSettingsForm({ initial }: Props) {
58
+ const router = useRouter();
59
+ const [pending, startTransition] = useTransition();
60
+ const [message, setMessage] = useState<string | null>(null);
61
+
62
+ const geminiMasked = isMaskedSecret(initial.geminiApiKey);
63
+ const alphaMasked = isMaskedSecret(initial.alphaWebhookSecret);
64
+ const phoneMasked = isMaskedSecret(initial.wabridgePhone);
65
+ const geminiWasSet =
66
+ geminiMasked || (!!initial.geminiApiKey && initial.geminiApiKey !== "");
67
+ const alphaWasSet =
68
+ alphaMasked ||
69
+ (!!initial.alphaWebhookSecret && initial.alphaWebhookSecret !== "");
70
+ const phoneWasSet =
71
+ phoneMasked || (!!initial.wabridgePhone && initial.wabridgePhone !== "");
72
+
73
+ const [geminiKey, setGeminiKey] = useState(() =>
74
+ geminiMasked ? MASK : (initial.geminiApiKey ?? ""),
75
+ );
76
+ const [alphaSecret, setAlphaSecret] = useState(() =>
77
+ alphaMasked ? MASK : (initial.alphaWebhookSecret ?? ""),
78
+ );
79
+ const [baseUrl, setBaseUrl] = useState(initial.wabridgeBaseUrl ?? "");
80
+ const [phone, setPhone] = useState(() =>
81
+ phoneMasked ? MASK : (initial.wabridgePhone ?? ""),
82
+ );
83
+ const [enabled, setEnabled] = useState(initial.wabridgeEnabled !== false);
84
+ const [showGeminiKey, setShowGeminiKey] = useState(false);
85
+ const [showAlphaSecret, setShowAlphaSecret] = useState(false);
86
+
87
+ useEffect(() => {
88
+ setGeminiKey(
89
+ isMaskedSecret(initial.geminiApiKey)
90
+ ? MASK
91
+ : (initial.geminiApiKey ?? ""),
92
+ );
93
+ setAlphaSecret(
94
+ isMaskedSecret(initial.alphaWebhookSecret)
95
+ ? MASK
96
+ : (initial.alphaWebhookSecret ?? ""),
97
+ );
98
+ setBaseUrl(initial.wabridgeBaseUrl ?? "");
99
+ setPhone(
100
+ isMaskedSecret(initial.wabridgePhone)
101
+ ? MASK
102
+ : (initial.wabridgePhone ?? ""),
103
+ );
104
+ setEnabled(initial.wabridgeEnabled !== false);
105
+ }, [initial]);
106
+
107
+ function submit() {
108
+ setMessage(null);
109
+ const payload: Parameters<typeof saveIntegrationSettings>[0] = {};
110
+
111
+ const g = secretFieldUpdate(geminiKey, initial.geminiApiKey);
112
+ if (g !== undefined) {
113
+ payload.geminiApiKey = g;
114
+ }
115
+
116
+ const a = secretFieldUpdate(alphaSecret, initial.alphaWebhookSecret);
117
+ if (a !== undefined) {
118
+ payload.alphaWebhookSecret = a;
119
+ }
120
+
121
+ payload.wabridgeBaseUrl = baseUrl.trim() || null;
122
+
123
+ const p = secretFieldUpdate(phone, initial.wabridgePhone);
124
+ if (p !== undefined) {
125
+ payload.wabridgePhone = p;
126
+ }
127
+
128
+ payload.wabridgeEnabled = enabled;
129
+
130
+ startTransition(async () => {
131
+ const r = await saveIntegrationSettings(payload);
132
+ if (!r.ok) {
133
+ setMessage(r.error ?? "Save failed");
134
+ return;
135
+ }
136
+ setMessage("Saved.");
137
+ router.refresh();
138
+ });
139
+ }
140
+
141
+ return (
142
+ <div className="flex flex-col gap-8 rounded-lg border border-neutral-200 bg-white/60 p-5 dark:border-neutral-800 dark:bg-neutral-950/40">
143
+ <p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
144
+ Secrets are encrypted in the gateway database. The backend reads them
145
+ when{" "}
146
+ <code className="rounded bg-neutral-100 px-1 text-xs dark:bg-neutral-900">
147
+ GEMINI_API_KEY
148
+ </code>{" "}
149
+ is unset. Configure{" "}
150
+ <code className="rounded bg-neutral-100 px-1 text-xs dark:bg-neutral-900">
151
+ INTEGRATIONS_ENCRYPTION_KEY
152
+ </code>
153
+ ,{" "}
154
+ <code className="rounded bg-neutral-100 px-1 text-xs dark:bg-neutral-900">
155
+ INTEGRATIONS_INTERNAL_SECRET
156
+ </code>
157
+ , and matching URLs on Next and FastAPI.
158
+ </p>
159
+
160
+ <section className="flex flex-col gap-4">
161
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
162
+ Backend (Gemini)
163
+ </h2>
164
+ <label className="flex flex-col gap-1.5">
165
+ <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
166
+ Gemini API key
167
+ </span>
168
+ <div className="relative">
169
+ <input
170
+ type={showGeminiKey ? "text" : "password"}
171
+ autoComplete="off"
172
+ value={geminiKey}
173
+ onChange={(e) => setGeminiKey(e.target.value)}
174
+ onFocus={(e) => {
175
+ if (geminiKey === MASK) {
176
+ e.target.select();
177
+ }
178
+ }}
179
+ className="w-full rounded-md border border-neutral-300 bg-white py-2 pl-3 pr-10 text-sm dark:border-neutral-700 dark:bg-neutral-950"
180
+ placeholder="Paste your API key"
181
+ />
182
+ <button
183
+ type="button"
184
+ aria-label={showGeminiKey ? "Hide API key" : "Show API key"}
185
+ aria-pressed={showGeminiKey}
186
+ onClick={() => setShowGeminiKey((v) => !v)}
187
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
188
+ >
189
+ {showGeminiKey ? (
190
+ <EyeOff className="size-4" aria-hidden />
191
+ ) : (
192
+ <Eye className="size-4" aria-hidden />
193
+ )}
194
+ </button>
195
+ </div>
196
+ <span className="text-xs text-neutral-500">
197
+ {geminiMasked
198
+ ? "Saved key is masked for your account. Admins see the real value. Replace, or clear and save to remove."
199
+ : geminiWasSet
200
+ ? "Replace or clear the field and save to remove."
201
+ : "Optional if the backend already has GEMINI_API_KEY."}
202
+ </span>
203
+ </label>
204
+ </section>
205
+
206
+ <section className="flex flex-col gap-4">
207
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
208
+ Alpha
209
+ </h2>
210
+ <label className="flex flex-col gap-1.5">
211
+ <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
212
+ Webhook secret
213
+ </span>
214
+ <div className="relative">
215
+ <input
216
+ type={showAlphaSecret ? "text" : "password"}
217
+ autoComplete="off"
218
+ value={alphaSecret}
219
+ onChange={(e) => setAlphaSecret(e.target.value)}
220
+ onFocus={(e) => {
221
+ if (alphaSecret === MASK) {
222
+ e.target.select();
223
+ }
224
+ }}
225
+ className="w-full rounded-md border border-neutral-300 bg-white py-2 pl-3 pr-10 text-sm dark:border-neutral-700 dark:bg-neutral-950"
226
+ placeholder="Webhook signing secret"
227
+ />
228
+ <button
229
+ type="button"
230
+ aria-label={
231
+ showAlphaSecret ? "Hide webhook secret" : "Show webhook secret"
232
+ }
233
+ aria-pressed={showAlphaSecret}
234
+ onClick={() => setShowAlphaSecret((v) => !v)}
235
+ className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
236
+ >
237
+ {showAlphaSecret ? (
238
+ <EyeOff className="size-4" aria-hidden />
239
+ ) : (
240
+ <Eye className="size-4" aria-hidden />
241
+ )}
242
+ </button>
243
+ </div>
244
+ <span className="text-xs text-neutral-500">
245
+ {alphaMasked
246
+ ? "Saved secret is masked for your account. Admins see the real value."
247
+ : alphaWasSet
248
+ ? "Replace or clear the field and save to remove."
249
+ : "Leave empty if you do not use Alpha webhooks."}
250
+ </span>
251
+ </label>
252
+ </section>
253
+
254
+ <section className="flex flex-col gap-4">
255
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
256
+ WABridge
257
+ </h2>
258
+ <label className="flex flex-col gap-1.5">
259
+ <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
260
+ Base URL
261
+ </span>
262
+ <input
263
+ type="url"
264
+ value={baseUrl}
265
+ onChange={(e) => setBaseUrl(e.target.value)}
266
+ className="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"
267
+ placeholder="http://127.0.0.1:8080"
268
+ />
269
+ </label>
270
+
271
+ <label className="flex flex-col gap-1.5">
272
+ <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
273
+ Phone (E.164, no +)
274
+ </span>
275
+ <input
276
+ type="text"
277
+ inputMode="numeric"
278
+ value={phone}
279
+ onChange={(e) => setPhone(e.target.value)}
280
+ onFocus={(e) => {
281
+ if (phone === MASK) {
282
+ e.target.select();
283
+ }
284
+ }}
285
+ className="rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"
286
+ placeholder="e.g. 15551234567"
287
+ />
288
+ <span className="text-xs text-neutral-500">
289
+ {phoneMasked
290
+ ? "Saved number is masked for your account. Admins see the real value."
291
+ : phoneWasSet
292
+ ? "Clear the field and save to use /send/self without a default number."
293
+ : "Optional default destination for delivery."}
294
+ </span>
295
+ </label>
296
+
297
+ <label className="flex cursor-pointer items-center gap-2.5 text-sm text-neutral-800 dark:text-neutral-200">
298
+ <input
299
+ type="checkbox"
300
+ checked={enabled}
301
+ onChange={(e) => setEnabled(e.target.checked)}
302
+ className="size-4 rounded border-neutral-300 dark:border-neutral-600"
303
+ />
304
+ Delivery enabled
305
+ </label>
306
+ </section>
307
+
308
+ {message ? (
309
+ <p
310
+ className={
311
+ message === "Saved."
312
+ ? "text-sm text-green-700 dark:text-green-400"
313
+ : "text-sm text-amber-800 dark:text-amber-200"
314
+ }
315
+ >
316
+ {message}
317
+ </p>
318
+ ) : null}
319
+
320
+ <button
321
+ type="button"
322
+ disabled={pending}
323
+ onClick={() => submit()}
324
+ className="rounded-md bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-neutral-100 dark:text-neutral-900"
325
+ >
326
+ {pending ? "Saving…" : "Save changes"}
327
+ </button>
328
+ </div>
329
+ );
330
+ }
@@ -0,0 +1,41 @@
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { loadIntegrationSettingsForForm } from "./actions";
4
+ import { IntegrationsSettingsForm } from "./integrations-settings-form";
5
+
6
+ export default async function IntegrationsSettingsPage() {
7
+ const result = await loadIntegrationSettingsForForm();
8
+ if (!result.ok && result.reason === "unauthorized") {
9
+ redirect("/auth/sign-in");
10
+ }
11
+ if (!result.ok) {
12
+ return (
13
+ <main className="mx-auto flex w-full max-w-xl flex-1 flex-col gap-6 p-6">
14
+ <Link
15
+ href="/"
16
+ className="text-sm text-neutral-500 hover:underline dark:text-neutral-400"
17
+ >
18
+ ← Home
19
+ </Link>
20
+ <p className="text-sm text-amber-800 dark:text-amber-200">
21
+ Could not load integration settings. Is the gateway running?
22
+ </p>
23
+ </main>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <main className="mx-auto flex w-full max-w-xl flex-1 flex-col gap-6 p-6">
29
+ <Link
30
+ href="/"
31
+ className="text-sm text-neutral-500 hover:underline dark:text-neutral-400"
32
+ >
33
+ ← Home
34
+ </Link>
35
+ <h1 className="text-2xl font-semibold tracking-tight">
36
+ Integration settings
37
+ </h1>
38
+ <IntegrationsSettingsForm initial={result.data} />
39
+ </main>
40
+ );
41
+ }
@@ -0,0 +1,84 @@
1
+ import { handleAlertCreated } from "@/lib/alpha-webhook/handler";
2
+ import { isValidAlphaWebhookSignature } from "@/lib/alpha-webhook/signature";
3
+ import type { AlertCreatedPayload } from "@/lib/alpha-webhook/types";
4
+ import { getIntegrationsConfig } from "@/lib/integrations-config";
5
+
6
+ export const runtime = "nodejs";
7
+
8
+ /** Allow outbound connector calls (e.g. WABridge fetch) to finish before the isolate freezes (Vercel). */
9
+ export const maxDuration = 60;
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ function isAlertCreatedPayload(value: unknown): value is AlertCreatedPayload {
16
+ if (!isRecord(value)) return false;
17
+ return (
18
+ typeof value._id === "string" &&
19
+ typeof value.alert_type === "string" &&
20
+ typeof value.date === "string" &&
21
+ typeof value.r2_key === "string" &&
22
+ typeof value.alert_string === "string"
23
+ );
24
+ }
25
+
26
+ export async function POST(request: Request): Promise<Response> {
27
+ const rawBody = await request.text();
28
+ const event = request.headers.get("x-alpha-webhook-event");
29
+ const signature = request.headers.get("x-alpha-signature");
30
+
31
+ if (!event?.trim()) {
32
+ return Response.json(
33
+ { error: "Missing X-Alpha-Webhook-Event" },
34
+ { status: 400 },
35
+ );
36
+ }
37
+
38
+ const { alphaWebhookSecret: secret } = await getIntegrationsConfig();
39
+ if (signature) {
40
+ if (!secret) {
41
+ return Response.json(
42
+ {
43
+ error:
44
+ "Signature present but webhook secret is not configured (SQLite or ALPHA_WEBHOOK_SECRET)",
45
+ },
46
+ { status: 401 },
47
+ );
48
+ }
49
+ if (!isValidAlphaWebhookSignature(rawBody, secret, signature)) {
50
+ return Response.json({ error: "Invalid signature" }, { status: 401 });
51
+ }
52
+ }
53
+
54
+ let payload: unknown;
55
+ try {
56
+ payload = JSON.parse(rawBody) as unknown;
57
+ } catch {
58
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
59
+ }
60
+
61
+ switch (event) {
62
+ case "alert.created": {
63
+ if (!isAlertCreatedPayload(payload)) {
64
+ return Response.json(
65
+ { error: "Payload does not match alert.created shape" },
66
+ { status: 400 },
67
+ );
68
+ }
69
+ await handleAlertCreated(payload).catch((err: unknown) => {
70
+ console.error(
71
+ "[alpha-webhook] handleAlertCreated failed:",
72
+ err instanceof Error ? err.message : err,
73
+ );
74
+ });
75
+ break;
76
+ }
77
+ default: {
78
+ // Closed receiver: unknown events are acknowledged without processing.
79
+ break;
80
+ }
81
+ }
82
+
83
+ return new Response(null, { status: 204 });
84
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { authClient } from "@/lib/auth-client";
5
+
6
+ export function HomeAuthPanel() {
7
+ const { data: session, isPending } = authClient.useSession();
8
+
9
+ if (isPending) {
10
+ return <p className="text-sm text-neutral-500">Checking session…</p>;
11
+ }
12
+
13
+ if (!session?.user) {
14
+ return (
15
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
16
+ <p className="text-sm text-neutral-600">You are signed out.</p>
17
+ <Link
18
+ className="inline-flex items-center justify-center rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800"
19
+ href="/auth/sign-in"
20
+ >
21
+ Sign in
22
+ </Link>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <div className="flex flex-col gap-2">
29
+ <p className="text-sm text-neutral-700">
30
+ Signed in as <span className="font-medium">{session.user.email}</span>
31
+ </p>
32
+ <div className="flex flex-wrap gap-2">
33
+ <Link
34
+ className="text-sm font-medium text-neutral-900 underline underline-offset-4"
35
+ href="/account/settings"
36
+ >
37
+ Account
38
+ </Link>
39
+ <button
40
+ className="text-sm font-medium text-red-700 underline underline-offset-4"
41
+ type="button"
42
+ onClick={() => authClient.signOut()}
43
+ >
44
+ Sign out
45
+ </button>
46
+ </div>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { AuthUIProvider } from "@daveyplate/better-auth-ui";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import Link from "next/link";
6
+ import { useRouter } from "next/navigation";
7
+ import type { ReactNode } from "react";
8
+ import { useState } from "react";
9
+ import { Toaster } from "sonner";
10
+ import { authClient } from "@/lib/auth-client";
11
+
12
+ function appBaseUrl(): string {
13
+ const raw = process.env.NEXT_PUBLIC_APP_URL?.trim();
14
+ if (raw) {
15
+ return raw.replace(/\/$/, "");
16
+ }
17
+ if (typeof window !== "undefined") {
18
+ return window.location.origin;
19
+ }
20
+ return "http://localhost:3000";
21
+ }
22
+
23
+ export function Providers({ children }: { children: ReactNode }) {
24
+ const router = useRouter();
25
+ const [queryClient] = useState(
26
+ () =>
27
+ new QueryClient({
28
+ defaultOptions: {
29
+ queries: {
30
+ staleTime: 60 * 1000,
31
+ },
32
+ },
33
+ }),
34
+ );
35
+
36
+ return (
37
+ <QueryClientProvider client={queryClient}>
38
+ <AuthUIProvider
39
+ authClient={authClient}
40
+ navigate={router.push}
41
+ replace={router.replace}
42
+ Link={Link}
43
+ baseURL={appBaseUrl()}
44
+ >
45
+ {children}
46
+ <Toaster closeButton position="top-center" richColors />
47
+ </AuthUIProvider>
48
+ </QueryClientProvider>
49
+ );
50
+ }
@@ -0,0 +1,35 @@
1
+ import { getIntegrationsConfig } from "@/lib/integrations-config";
2
+ import type { AlertCreatedPayload } from "../types";
3
+ import type { AlertConnector } from "./types";
4
+ import { createWabridgeConnector } from "./wabridge";
5
+
6
+ /** Runs all enabled connectors in parallel; failures are logged and do not throw. */
7
+ export async function deliverAlertPayload(
8
+ payload: AlertCreatedPayload,
9
+ ): Promise<void> {
10
+ const cfg = await getIntegrationsConfig();
11
+ const connectors: AlertConnector[] = [];
12
+ const wabridge = createWabridgeConnector(cfg);
13
+ if (wabridge) {
14
+ connectors.push(wabridge);
15
+ }
16
+ if (connectors.length === 0) {
17
+ return;
18
+ }
19
+
20
+ const results = await Promise.allSettled(
21
+ connectors.map((c) => c.deliver(payload)),
22
+ );
23
+
24
+ for (let i = 0; i < results.length; i++) {
25
+ const r = results[i];
26
+ if (r.status === "rejected") {
27
+ const id = connectors[i].id;
28
+ const err = r.reason;
29
+ console.error(
30
+ `[alpha-webhook] connector "${id}" failed:`,
31
+ err instanceof Error ? err.message : err,
32
+ );
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,8 @@
1
+ import type { AlertCreatedPayload } from "../types";
2
+
3
+ /** Outbound channel for a verified `alert.created` payload (WhatsApp, Slack, etc.). */
4
+ export type AlertConnector = {
5
+ /** Stable id for logs and metrics (e.g. `wabridge`, `slack`). */
6
+ id: string;
7
+ deliver(payload: AlertCreatedPayload): Promise<void>;
8
+ };