Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions api/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,10 +469,16 @@
return c.json({ error: "partnerBusinessId required" }, 400);
}
const targetBusinessId = body.initiatorBusinessId ? Number(body.initiatorBusinessId) : null;
if (!body.initiatorSystem || typeof body.initiatorSystem !== "string") {
return c.json({ error: "initiatorSystem required" }, 400);
}
if (!body.initiatorApiUrl || typeof body.initiatorApiUrl !== "string") {
return c.json({ error: "initiatorApiUrl required" }, 400);
}
const result = await completeReverseConnection({
businessId,
targetSystem: String(body.initiatorSystem ?? "finabill"),
targetUrl: String(body.initiatorApiUrl ?? ""),
targetSystem: String(body.initiatorSystem),
targetUrl: String(body.initiatorApiUrl),
apiKey: String(body.initiatorApiKey ?? ""),
webhookSecret: String(body.webhookSecret ?? ""),
scopes: Array.isArray(body.scopes) ? body.scopes : undefined,
Expand All @@ -489,7 +495,7 @@
});

app.get("/api/connect/sessions/:sessionPublicId", connectLimiter, async (c) => {
const session = await getConnectSessionPublic(c.req.param("sessionPublicId"));

Check failure on line 498 in api/boot.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
if (!session) return c.json({ error: "Not found" }, 404);
return c.json(session);
});
Expand Down
12 changes: 11 additions & 1 deletion api/connect-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "./lib/integrations/connect-service";
import { DEFAULT_CONNECT_SCOPES } from "./lib/api-scopes";
import { env } from "./lib/env";
import { assertSafeOutboundUrl } from "./lib/url-guard";

function requireBusinessId(ctx: {
user?: { currentBusiness?: { id: number } | null; currentBusinessId?: number | null };
Expand Down Expand Up @@ -41,6 +42,7 @@ export const connectRouter = createRouter({
.object({
scopes: z.array(z.string()).optional(),
mode: z.enum(["redirect", "pairing"]).optional(),
targetSystem: z.string().min(2).max(50).optional(),
})
.optional()
)
Expand All @@ -50,6 +52,7 @@ export const connectRouter = createRouter({
businessId,
userId: ctx.user!.id,
scopes: input?.scopes,
targetSystem: input?.targetSystem,
});
}),

Expand Down Expand Up @@ -97,18 +100,25 @@ export const connectRouter = createRouter({
sessionPublicId: z.string().min(8),
state: z.string().min(8),
scopes: z.array(z.string()).optional(),
initiatorSystem: z.string().min(2).max(50).optional(),
})
)
.mutation(async ({ input, ctx }) => {
const businessId = requireBusinessId(ctx);
try {
// The server POSTs fresh admin-scoped credentials to this URL — never
// allow internal/private targets or cleartext http in prod.
const safeUrl = await assertSafeOutboundUrl(input.initiatorApiUrl, {
allowHttp: process.env.NODE_ENV !== "production",
});
return await approveAsPartner({
initiatorApiUrl: input.initiatorApiUrl,
initiatorApiUrl: safeUrl.origin,
sessionPublicId: input.sessionPublicId,
state: input.state,
businessId,
userId: ctx.user!.id,
scopes: input.scopes,
initiatorSystem: input.initiatorSystem,
});
} catch (err) {
throw new TRPCError({
Expand Down
4 changes: 4 additions & 0 deletions api/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const env = {
apiUrl: process.env.API_URL || process.env.VITE_API_URL || "http://localhost:3200",
finabillAppUrl: process.env.FINABILL_APP_URL || "http://localhost:5174",
finabillApiUrl: process.env.FINABILL_API_URL || "http://localhost:3100",
// Glomish peer (salon/beauty-store platform) — Fina Connect sibling
glomishAppUrl: process.env.GLOMISH_APP_URL || "http://localhost:3000",
glomishApiUrl:
process.env.GLOMISH_API_URL || process.env.GLOMISH_APP_URL || "http://localhost:3000",
systemName: "finaflow" as const,
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS || "12", 10),
nhifRate: parseFloat(process.env.NHIF_RATE || "2.75"),
Expand Down
3 changes: 2 additions & 1 deletion api/lib/integrations/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export type IntegrationFeature =
| "daily_sales.ingest"
| "supplier.push"
| "journal.push"
| "webhook.incoming";
| "webhook.incoming"
| "fina_connect";

export type IntegrationAdapterMetadata = {
targetSystem: string;
Expand Down
60 changes: 60 additions & 0 deletions api/lib/integrations/adapters/glomish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// ABOUTME: Glomish adapter — a salon/beauty-store platform that pushes daily
// sales into finaflow (aggregated by payment channel per day) when autosync is
// enabled, so the business can log expenses here while Glomish handles bookings.
import type { IntegrationAdapter, IntegrationConnection } from "../adapter.js";

export const GLOMISH_TARGET_SYSTEM = "glomish";

export const glomishAdapter: IntegrationAdapter = {
targetSystem: GLOMISH_TARGET_SYSTEM,
name: "Glomish",
description:
"Receive daily sales from Glomish (salon & beauty-store platform) with automatic sync.",
authMode: "api_key",
credentialFields: [
{
name: "targetUrl",
label: "Glomish base URL",
type: "url",
required: false,
placeholder: "https://api.glomish.example",
},
{
name: "apiKey",
label: "Glomish API key",
type: "password",
required: false,
placeholder: "Optional: for testing Glomish outbound",
},
],
scopes: [
{ value: "read", label: "Read", description: "Pull reference data from Glomish" },
{ value: "write", label: "Write", description: "Post back to Glomish" },
{ value: "sales", label: "Sales", description: "Ingest daily sales from Glomish" },
{ value: "webhooks", label: "Webhooks", description: "Receive Glomish webhook events" },
],
features: ["daily_sales.ingest", "fina_connect"],

async testConnection(connection: IntegrationConnection) {
const url = connection.credentials.url;
const apiKey = connection.credentials.apiKey;
if (!url) {
return { ok: false, error: "Glomish base URL is required" };
}

try {
// Glomish exposes machine verification at /api/v1/verify.
const base = url.replace(/\/$/, "");
const res = await fetch(`${base}/api/v1/verify`, {
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
});
if (!res.ok) {
return { ok: false, error: `Glomish returned ${res.status}` };
}
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return { ok: false, error: message };
}
},
};
74 changes: 57 additions & 17 deletions api/lib/integrations/connect-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { DEFAULT_CONNECT_SCOPES } from "../api-scopes";

const SESSION_TTL_MS = 10 * 60 * 1000;
const SYSTEM = "finaflow" as const;
const SIBLING = "finabill" as const;

function randomToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString("base64url");
Expand Down Expand Up @@ -181,12 +180,29 @@ export async function upsertSiblingConnection(input: {
return { id: created.id };
}

/** Per-partner origin URLs for Fina Connect pairing sessions. */
function partnerUrlsFor(targetSystem: string): { appUrl: string; apiUrl: string } {
if (targetSystem === "glomish") {
return {
appUrl: env.glomishAppUrl.replace(/\/$/, ""),
apiUrl: env.glomishApiUrl.replace(/\/$/, ""),
};
}
return {
appUrl: env.finabillAppUrl.replace(/\/$/, ""),
apiUrl: env.finabillApiUrl.replace(/\/$/, ""),
};
}

export async function createConnectSession(input: {
businessId: number;
userId: number;
scopes?: string[];
/** Partner system to pair with ("finabill" default, "glomish" supported). */
targetSystem?: string;
}) {
const db = getDb();
const targetSystem = input.targetSystem ?? "finabill";
const pairingCode = generatePairingCode();
const sessionPublicId = randomToken(18);
const state = randomToken(24);
Expand All @@ -195,8 +211,8 @@ export async function createConnectSession(input: {
const scopes = input.scopes?.length ? input.scopes : [...DEFAULT_CONNECT_SCOPES];
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);

const partnerAppUrl = env.finabillAppUrl.replace(/\/$/, "");
const partnerApiUrl = env.finabillApiUrl.replace(/\/$/, "");
const { appUrl: partnerAppUrl, apiUrl: partnerApiUrl } =
partnerUrlsFor(targetSystem);
const initiatorAppUrl = env.appUrl.replace(/\/$/, "");
const initiatorApiUrl = env.apiUrl.replace(/\/$/, "");

Expand All @@ -207,21 +223,21 @@ export async function createConnectSession(input: {
.where(
and(
eq(integrationConnections.businessId, input.businessId),
eq(integrationConnections.targetSystem, SIBLING),
eq(integrationConnections.targetSystem, targetSystem),
isNull(integrationConnections.deletedAt)
)
)
.limit(1);
if (existingConnection?.isActive) {
throw new Error(
`This business is already connected to ${SIBLING}. Disconnect or switch businesses before connecting again.`
`This business is already connected to ${targetSystem}. Disconnect or switch businesses before connecting again.`
);
}

await db.insert(integrationConnectSessions).values({
sessionPublicId,
initiatorSystem: SYSTEM,
partnerSystem: SIBLING,
partnerSystem: targetSystem,
initiatorBusinessId: input.businessId,
partnerBusinessId: null,
codeHash: hashCode(pairingCode),
Expand All @@ -248,7 +264,7 @@ export async function createConnectSession(input: {
`&initiator_api=${encodeURIComponent(initiatorApiUrl)}` +
`&initiator_app=${encodeURIComponent(initiatorAppUrl)}` +
`&initiator_system=${SYSTEM}` +
`&partner_system=${SIBLING}`;
`&partner_system=${encodeURIComponent(targetSystem)}`;

return {
sessionPublicId,
Expand All @@ -259,6 +275,7 @@ export async function createConnectSession(input: {
expiresAt,
partnerAppUrl,
partnerApiUrl,
targetSystem,
};
}

Expand Down Expand Up @@ -290,11 +307,28 @@ export async function getConnectSessionPublic(sessionPublicId: string) {
initiatorAppUrl: session.initiatorAppUrl,
partnerApiUrl: session.partnerApiUrl,
partnerAppUrl: session.partnerAppUrl,
state: session.state,
codeChallenge: session.codeChallenge,
};
}

/**
* Full session shape INCLUDING `state` — only for callers that have proven
* possession of the pairing code. The public session-id lookup must not leak
* it (partner-approve authenticates with sessionPublicId + state, so exposing
* state by id alone would collapse pairing security to the session id).
*/
export async function getConnectSessionWithState(sessionPublicId: string) {
const session = await getConnectSessionPublic(sessionPublicId);
if (!session) return null;
const db = getDb();
const [row] = await db
.select({ state: integrationConnectSessions.state })
.from(integrationConnectSessions)
.where(eq(integrationConnectSessions.sessionPublicId, sessionPublicId))
.limit(1);
return { ...session, state: row?.state ?? null };
}

export async function getConnectSessionStatus(sessionPublicId: string, businessId: number) {
const db = getDb();
const [session] = await db
Expand Down Expand Up @@ -443,18 +477,21 @@ export async function exchangeConnectSession(input: {
? (payload.partnerScopes as string[])
: [...DEFAULT_CONNECT_SCOPES];

const partnerApiUrl = (session.partnerApiUrl || env.finabillApiUrl).replace(/\/$/, "");
const partnerSystem = session.partnerSystem;
const partnerApiUrl = (
session.partnerApiUrl || partnerUrlsFor(partnerSystem).apiUrl
).replace(/\/$/, "");
if (!partnerApiUrl) throw new Error("Partner API URL missing");
const selfApi = env.apiUrl.replace(/\/$/, "");
if (partnerApiUrl === selfApi) {
throw new Error(
`Partner API URL points at this app (${partnerApiUrl}). Set FINABILL_API_URL on FinaFlow and API_URL on FinaBill to the backend origins.`
`Partner API URL points at this app (${partnerApiUrl}). Set the ${partnerSystem} API URL and this app's API_URL to the backend origins.`
);
}

const inbound = await createInboundApiKey(
input.businessId,
`FinaBill connect ${new Date().toISOString().slice(0, 10)}`,
`${partnerSystem === "finabill" ? "FinaBill" : partnerSystem} connect ${new Date().toISOString().slice(0, 10)}`,
scopes
);

Expand All @@ -467,7 +504,7 @@ export async function exchangeConnectSession(input: {

const connection = await upsertSiblingConnection({
businessId: input.businessId,
targetSystem: SIBLING,
targetSystem: partnerSystem,
targetUrl: partnerApiUrl,
apiKey: partnerApiKey,
webhookSecret,
Expand Down Expand Up @@ -524,7 +561,7 @@ export async function exchangeConnectSession(input: {
return {
success: true,
connectionId: connection.id,
targetSystem: SIBLING,
targetSystem: partnerSystem,
bootstrap: { ok: true },
};
}
Expand All @@ -550,8 +587,9 @@ export async function completeReverseConnection(input: {
targetBusinessName: input.targetBusinessName,
});

// FinaBill-initiated pairing: register the outgoing event subscription too.
if (input.targetSystem === SIBLING) {
// Partner-initiated pairing: register the outgoing event subscription too
// (both FinaBill and Glomish expose a signed /api/webhooks/finaflow receiver).
if (input.targetSystem === "finabill" || input.targetSystem === "glomish") {
try {
await ensureSiblingWebhookSubscription(
input.businessId,
Expand All @@ -575,9 +613,10 @@ export async function approveAsPartner(input: {
scopes?: string[];
}) {
const scopes = input.scopes?.length ? input.scopes : [...DEFAULT_CONNECT_SCOPES];
const initiatorLabel = input.initiatorSystem ?? "finabill";
const inbound = await createInboundApiKey(
input.businessId,
`FinaBill connect ${new Date().toISOString().slice(0, 10)}`,
`${initiatorLabel === "finabill" ? "FinaBill" : initiatorLabel} connect ${new Date().toISOString().slice(0, 10)}`,
scopes
);
const webhookSecret = randomToken(32);
Expand Down Expand Up @@ -698,7 +737,8 @@ export async function resolvePairingCodeOnInitiator(pairingCode: string) {
if (session.status !== "pending") continue;
if (session.expiresAt.getTime() < Date.now()) continue;
if (hashCode(pairingCode) === session.codeHash) {
return getConnectSessionPublic(session.sessionPublicId);
// Pairing code proven — state is safe to expose now (partner-approve needs it).
return getConnectSessionWithState(session.sessionPublicId);
}
}
return null;
Expand Down
2 changes: 2 additions & 0 deletions api/lib/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { registerAdapter } from "./registry.js";
import { finabillAdapter } from "./adapters/finabill.js";
import { glomishAdapter } from "./adapters/glomish.js";

export { getAdapter, listAdapters, registerAdapter, clearAdapters } from "./registry.js";
export type { IntegrationAdapter, IntegrationConnection, IntegrationFeature } from "./adapter.js";

// Register built-in adapters
registerAdapter(finabillAdapter);
registerAdapter(glomishAdapter);
Loading
Loading