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,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
|
+
};
|