feat: zentrale selfhost-dienste bereitstellen
This commit is contained in:
@@ -225,6 +225,12 @@ DOKUBOX_IMAP_PASSWORD=change-this-imap-password
|
|||||||
OPENAI_API_KEY=replace-this
|
OPENAI_API_KEY=replace-this
|
||||||
STIRLING_API_KEY=replace-this
|
STIRLING_API_KEY=replace-this
|
||||||
|
|
||||||
|
# Optional: zentrale, durch den FEDEO-Betreiber bereitgestellte Dienste
|
||||||
|
FEDEO_CENTRAL_SERVICES_ENABLED=false
|
||||||
|
PUSH_SERVER_URL=https://services.fedeo.de
|
||||||
|
PUSH_SERVER_INSTANCE_ID=
|
||||||
|
PUSH_SERVER_SECRET=
|
||||||
|
|
||||||
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
|
||||||
|
|
||||||
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// /services/bankStatementService.ts
|
// /services/bankStatementService.ts
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
|
import { centralServicesClient } from "../push-server.client"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import utc from "dayjs/plugin/utc.js"
|
import utc from "dayjs/plugin/utc.js"
|
||||||
import {secrets} from "../../utils/secrets"
|
import {secrets} from "../../utils/secrets"
|
||||||
@@ -57,11 +58,13 @@ const normalizeDate = (val: any) => {
|
|||||||
export function bankStatementService(server: FastifyInstance) {
|
export function bankStatementService(server: FastifyInstance) {
|
||||||
|
|
||||||
let accessToken: string | null = null
|
let accessToken: string | null = null
|
||||||
|
const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())
|
||||||
|
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
// ✔ TOKEN LADEN
|
// ✔ TOKEN LADEN
|
||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
const getToken = async () => {
|
const getToken = async () => {
|
||||||
|
if (useCentralBanking) return
|
||||||
console.log("Fetching GoCardless token…")
|
console.log("Fetching GoCardless token…")
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
@@ -80,6 +83,7 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
const getBalanceData = async (accountId: string): Promise<any | false> => {
|
const getBalanceData = async (accountId: string): Promise<any | false> => {
|
||||||
try {
|
try {
|
||||||
|
if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId)
|
||||||
const {data} = await axios.get(
|
const {data} = await axios.get(
|
||||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`,
|
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`,
|
||||||
{
|
{
|
||||||
@@ -114,6 +118,10 @@ export function bankStatementService(server: FastifyInstance) {
|
|||||||
// -----------------------------------------------
|
// -----------------------------------------------
|
||||||
const getTransactionData = async (accountId: string) => {
|
const getTransactionData = async (accountId: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (useCentralBanking) {
|
||||||
|
const data = await centralServicesClient.getBankingTransactions(accountId)
|
||||||
|
return data.transactions.booked
|
||||||
|
}
|
||||||
const {data} = await axios.get<TransactionsResponse>(
|
const {data} = await axios.get<TransactionsResponse>(
|
||||||
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`,
|
`${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash, createHmac } from "node:crypto"
|
import { createHash, createHmac, randomUUID } from "node:crypto"
|
||||||
|
|
||||||
import { secrets } from "../utils/secrets"
|
import { secrets } from "../utils/secrets"
|
||||||
|
|
||||||
@@ -54,13 +54,14 @@ function signature(method: string, path: string, timestamp: string, body: string
|
|||||||
return createHmac("sha256", secrets.PUSH_SERVER_SECRET).update(canonical).digest("hex")
|
return createHmac("sha256", secrets.PUSH_SERVER_SECRET).update(canonical).digest("hex")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestPushServer<T>(method: "POST" | "DELETE", path: string, payload?: unknown): Promise<T> {
|
async function requestPushServer<T>(method: "GET" | "POST" | "DELETE", path: string, payload?: unknown): Promise<T> {
|
||||||
if (!configured()) {
|
if (!configured()) {
|
||||||
throw new Error("Zentraler Push-Server ist nicht konfiguriert")
|
throw new Error("Zentraler Push-Server ist nicht konfiguriert")
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = payload === undefined ? "" : JSON.stringify(payload)
|
const body = payload === undefined ? "" : JSON.stringify(payload)
|
||||||
const timestamp = new Date().toISOString()
|
const timestamp = new Date().toISOString()
|
||||||
|
const signedPath = path.split("?")[0]
|
||||||
const response = await fetch(`${normalizeBaseUrl()}${path}`, {
|
const response = await fetch(`${normalizeBaseUrl()}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -68,7 +69,8 @@ async function requestPushServer<T>(method: "POST" | "DELETE", path: string, pay
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Fedeo-Instance-Id": secrets.PUSH_SERVER_INSTANCE_ID,
|
"X-Fedeo-Instance-Id": secrets.PUSH_SERVER_INSTANCE_ID,
|
||||||
"X-Fedeo-Timestamp": timestamp,
|
"X-Fedeo-Timestamp": timestamp,
|
||||||
"X-Fedeo-Signature": signature(method, path, timestamp, body),
|
"X-Fedeo-Signature": signature(method, signedPath, timestamp, body),
|
||||||
|
"X-Fedeo-Request-Id": randomUUID(),
|
||||||
},
|
},
|
||||||
body: body || undefined,
|
body: body || undefined,
|
||||||
})
|
})
|
||||||
@@ -99,3 +101,35 @@ export const pushServerClient = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const centralServicesClient = {
|
||||||
|
configured,
|
||||||
|
|
||||||
|
aiChatCompletions(payload: Record<string, unknown>) {
|
||||||
|
return requestPushServer<any>("POST", "/v1/services/ai/chat-completions", payload)
|
||||||
|
},
|
||||||
|
|
||||||
|
bankingInstitutions(country = "DE") {
|
||||||
|
return requestPushServer<any[]>("GET", `/v1/services/banking/institutions?country=${encodeURIComponent(country)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
createBankingRequisition(input: { institutionId: string; redirect: string; userLanguage?: string }) {
|
||||||
|
return requestPushServer<any>("POST", "/v1/services/banking/requisitions", input)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBankingRequisition(id: string) {
|
||||||
|
return requestPushServer<any>("GET", `/v1/services/banking/requisitions/${encodeURIComponent(id)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBankingAccount(id: string) {
|
||||||
|
return requestPushServer<any>("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBankingBalances(id: string) {
|
||||||
|
return requestPushServer<any>("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}/balances`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBankingTransactions(id: string) {
|
||||||
|
return requestPushServer<any>("GET", `/v1/services/banking/accounts/${encodeURIComponent(id)}/transactions`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { insertHistoryItem } from "../utils/history"
|
|||||||
import { decrypt, encrypt } from "../utils/crypt"
|
import { decrypt, encrypt } from "../utils/crypt"
|
||||||
import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
|
import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
|
||||||
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
|
||||||
|
import { centralServicesClient } from "../modules/push-server.client"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
@@ -902,6 +903,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL
|
const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL
|
||||||
const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID
|
const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID
|
||||||
const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY
|
const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY
|
||||||
|
const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())
|
||||||
|
|
||||||
let tokenData: any = null
|
let tokenData: any = null
|
||||||
|
|
||||||
@@ -918,6 +920,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkToken = async () => {
|
const checkToken = async () => {
|
||||||
|
if (useCentralBanking) return
|
||||||
if (!tokenData) return await getToken()
|
if (!tokenData) return await getToken()
|
||||||
|
|
||||||
const expired = dayjs(tokenData.created_at)
|
const expired = dayjs(tokenData.created_at)
|
||||||
@@ -935,24 +938,23 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
server.get("/banking/link/:institutionid", async (req, reply) => {
|
server.get("/banking/link/:institutionid", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
await checkToken()
|
|
||||||
|
|
||||||
const { institutionid } = req.params as { institutionid: string }
|
const { institutionid } = req.params as { institutionid: string }
|
||||||
const tenantId = req.user?.tenant_id
|
const tenantId = req.user?.tenant_id
|
||||||
|
|
||||||
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
|
||||||
const { data } = await axios.post(
|
const redirect = new URL("/settings/banking", secrets.API_BASE_URL).toString()
|
||||||
`${goCardLessBaseUrl}/requisitions/`,
|
let data: any
|
||||||
{
|
if (useCentralBanking) {
|
||||||
redirect: "https://app.fedeo.de/settings/banking",
|
data = await centralServicesClient.createBankingRequisition({ institutionId: institutionid, redirect, userLanguage: "de" })
|
||||||
institution_id: institutionid,
|
} else {
|
||||||
user_language: "de",
|
await checkToken()
|
||||||
},
|
;({ data } = await axios.post(
|
||||||
{
|
`${goCardLessBaseUrl}/requisitions/`,
|
||||||
headers: { Authorization: `Bearer ${tokenData.access}` },
|
{ redirect, institution_id: institutionid, user_language: "de" },
|
||||||
}
|
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||||
)
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// DB: Requisition speichern
|
// DB: Requisition speichern
|
||||||
await server.db.insert(bankrequisitions).values({
|
await server.db.insert(bankrequisitions).values({
|
||||||
@@ -977,12 +979,16 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const { bic } = req.params as { bic: string }
|
const { bic } = req.params as { bic: string }
|
||||||
if (!bic) return reply.code(400).send("BIC missing")
|
if (!bic) return reply.code(400).send("BIC missing")
|
||||||
|
|
||||||
await checkToken()
|
let data: any[]
|
||||||
|
if (useCentralBanking) {
|
||||||
const { data } = await axios.get(
|
data = await centralServicesClient.bankingInstitutions("DE")
|
||||||
`${goCardLessBaseUrl}/institutions/?country=de`,
|
} else {
|
||||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
await checkToken()
|
||||||
)
|
;({ data } = await axios.get(
|
||||||
|
`${goCardLessBaseUrl}/institutions/?country=de`,
|
||||||
|
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase())
|
const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase())
|
||||||
|
|
||||||
@@ -1004,21 +1010,23 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
const { reqId } = req.params as { reqId: string }
|
const { reqId } = req.params as { reqId: string }
|
||||||
if (!reqId) return reply.code(400).send("Requisition ID missing")
|
if (!reqId) return reply.code(400).send("Requisition ID missing")
|
||||||
|
|
||||||
await checkToken()
|
let data: any
|
||||||
|
if (useCentralBanking) {
|
||||||
const { data } = await axios.get(
|
data = await centralServicesClient.getBankingRequisition(reqId)
|
||||||
`${goCardLessBaseUrl}/requisitions/${reqId}`,
|
} else {
|
||||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
await checkToken()
|
||||||
)
|
;({ data } = await axios.get(
|
||||||
|
`${goCardLessBaseUrl}/requisitions/${reqId}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// Load account details
|
// Load account details
|
||||||
if (data.accounts) {
|
if (data.accounts) {
|
||||||
data.accounts = await Promise.all(
|
data.accounts = await Promise.all(
|
||||||
data.accounts.map(async (accId: string) => {
|
data.accounts.map(async (accId: string) => {
|
||||||
const { data: acc } = await axios.get(
|
if (useCentralBanking) return centralServicesClient.getBankingAccount(accId)
|
||||||
`${goCardLessBaseUrl}/accounts/${accId}`,
|
const { data: acc } = await axios.get(`${goCardLessBaseUrl}/accounts/${accId}`, { headers: { Authorization: `Bearer ${tokenData.access}` } })
|
||||||
{ headers: { Authorization: `Bearer ${tokenData.access}` } }
|
|
||||||
)
|
|
||||||
return acc
|
return acc
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FastifyInstance } from "fastify";
|
|||||||
import { storeExtractedTextForFile } from "./documentText";
|
import { storeExtractedTextForFile } from "./documentText";
|
||||||
import { loadFileBuffer } from "./fileBuffer";
|
import { loadFileBuffer } from "./fileBuffer";
|
||||||
import { secrets } from "./secrets";
|
import { secrets } from "./secrets";
|
||||||
|
import { centralServicesClient } from "../modules/push-server.client";
|
||||||
|
|
||||||
// Drizzle schema
|
// Drizzle schema
|
||||||
import { vendors, accounts, tenants } from "../../db/schema";
|
import { vendors, accounts, tenants } from "../../db/schema";
|
||||||
@@ -21,6 +22,7 @@ const nullableNumber = z.number().nullable();
|
|||||||
// INITIALIZE OPENAI
|
// INITIALIZE OPENAI
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
export const initOpenAi = async () => {
|
export const initOpenAi = async () => {
|
||||||
|
if (secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()) return;
|
||||||
openai = new OpenAI({
|
openai = new OpenAI({
|
||||||
apiKey: secrets.OPENAI_API_KEY,
|
apiKey: secrets.OPENAI_API_KEY,
|
||||||
});
|
});
|
||||||
@@ -84,8 +86,9 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
suppliedFileData?: Buffer,
|
suppliedFileData?: Buffer,
|
||||||
) {
|
) {
|
||||||
await initOpenAi();
|
await initOpenAi();
|
||||||
|
const useCentralAi = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured());
|
||||||
|
|
||||||
if (!openai) {
|
if (!useCentralAi && !openai) {
|
||||||
throw new Error("OpenAI not initialized. Call initOpenAi() first.");
|
throw new Error("OpenAI not initialized. Call initOpenAi() first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +167,7 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const completion = await openai.chat.completions.parse({
|
const completionRequest: any = {
|
||||||
model: "gpt-4o",
|
model: "gpt-4o",
|
||||||
store: true,
|
store: true,
|
||||||
response_format: zodResponseFormat(InstructionFormat as any, "instruction"),
|
response_format: zodResponseFormat(InstructionFormat as any, "instruction"),
|
||||||
@@ -190,9 +193,13 @@ export const getInvoiceDataFromGPT = async function (
|
|||||||
"Keep invoice items in original order.\n",
|
"Keep invoice items in original order.\n",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
};
|
||||||
|
const completion = useCentralAi
|
||||||
const parsed = completion.choices[0].message.parsed;
|
? await centralServicesClient.aiChatCompletions(completionRequest)
|
||||||
|
: await openai!.chat.completions.parse(completionRequest);
|
||||||
|
const parsed = useCentralAi
|
||||||
|
? InstructionFormat.parse(JSON.parse(completion.choices[0]?.message?.content || "{}"))
|
||||||
|
: completion.choices[0].message.parsed;
|
||||||
|
|
||||||
console.log(`🧾 Extracted invoice data for file ${file.id}`);
|
console.log(`🧾 Extracted invoice data for file ${file.id}`);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { zodResponseFormat } from "openai/helpers/zod";
|
|||||||
import { and, desc, eq, gte } from "drizzle-orm";
|
import { and, desc, eq, gte } from "drizzle-orm";
|
||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { centralServicesClient } from "../modules/push-server.client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankaccounts,
|
bankaccounts,
|
||||||
@@ -382,9 +383,10 @@ const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
|
const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise<RecurringCandidate[]> => {
|
||||||
if (!secrets.OPENAI_API_KEY || statements.length < 6) return [];
|
const useCentralAi = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured());
|
||||||
|
if ((!secrets.OPENAI_API_KEY && !useCentralAi) || statements.length < 6) return [];
|
||||||
|
|
||||||
const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
|
const openai = useCentralAi ? null : new OpenAI({ apiKey: secrets.OPENAI_API_KEY });
|
||||||
const compactStatements = statements.slice(0, 220).map((statement) => ({
|
const compactStatements = statements.slice(0, 220).map((statement) => ({
|
||||||
date: statement.valueDate || statement.date,
|
date: statement.valueDate || statement.date,
|
||||||
amount: roundMoney(Number(statement.amount || 0)),
|
amount: roundMoney(Number(statement.amount || 0)),
|
||||||
@@ -393,7 +395,7 @@ const detectRecurringWithAi = async (server: FastifyInstance, statements: any[])
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const completion = await openai.chat.completions.parse({
|
const completionRequest: any = {
|
||||||
model: "gpt-4o",
|
model: "gpt-4o",
|
||||||
store: true,
|
store: true,
|
||||||
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
|
response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"),
|
||||||
@@ -416,9 +418,15 @@ const detectRecurringWithAi = async (server: FastifyInstance, statements: any[])
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
};
|
||||||
|
const completion = useCentralAi
|
||||||
|
? await centralServicesClient.aiChatCompletions(completionRequest)
|
||||||
|
: await openai!.chat.completions.parse(completionRequest);
|
||||||
|
const parsed = useCentralAi
|
||||||
|
? AiRecurringFormat.parse(JSON.parse(completion.choices[0]?.message?.content || "{}"))
|
||||||
|
: completion.choices[0].message.parsed;
|
||||||
|
|
||||||
return (completion.choices[0].message.parsed?.candidates || [])
|
return (parsed?.candidates || [])
|
||||||
.filter((candidate) => dayjs(candidate.nextDate).isValid())
|
.filter((candidate) => dayjs(candidate.nextDate).isValid())
|
||||||
.filter((candidate) => Number(candidate.amount || 0) < 0)
|
.filter((candidate) => Number(candidate.amount || 0) < 0)
|
||||||
.map((candidate) => ({
|
.map((candidate) => ({
|
||||||
@@ -832,7 +840,7 @@ export const generateLiquidityForecast = async (
|
|||||||
},
|
},
|
||||||
points,
|
points,
|
||||||
ai: {
|
ai: {
|
||||||
enabled: Boolean(secrets.OPENAI_API_KEY),
|
enabled: Boolean(secrets.OPENAI_API_KEY || (secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())),
|
||||||
candidates: aiRecurring.length,
|
candidates: aiRecurring.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export let secrets = {
|
|||||||
PUSH_SERVER_URL?: string
|
PUSH_SERVER_URL?: string
|
||||||
PUSH_SERVER_INSTANCE_ID?: string
|
PUSH_SERVER_INSTANCE_ID?: string
|
||||||
PUSH_SERVER_SECRET?: string
|
PUSH_SERVER_SECRET?: string
|
||||||
|
FEDEO_CENTRAL_SERVICES_ENABLED?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const secretKeys = [
|
const secretKeys = [
|
||||||
@@ -100,10 +101,11 @@ const secretKeys = [
|
|||||||
"PUSH_SERVER_URL",
|
"PUSH_SERVER_URL",
|
||||||
"PUSH_SERVER_INSTANCE_ID",
|
"PUSH_SERVER_INSTANCE_ID",
|
||||||
"PUSH_SERVER_SECRET",
|
"PUSH_SERVER_SECRET",
|
||||||
|
"FEDEO_CENTRAL_SERVICES_ENABLED",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"])
|
||||||
const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE"])
|
const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE", "FEDEO_CENTRAL_SERVICES_ENABLED"])
|
||||||
|
|
||||||
function normalizeEnvValue(key: string, value: string) {
|
function normalizeEnvValue(key: string, value: string) {
|
||||||
if (numberKeys.has(key)) return Number(value)
|
if (numberKeys.has(key)) return Number(value)
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ services:
|
|||||||
DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER}
|
DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER}
|
||||||
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
|
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
FEDEO_CENTRAL_SERVICES_ENABLED: ${FEDEO_CENTRAL_SERVICES_ENABLED:-false}
|
||||||
|
PUSH_SERVER_URL: ${PUSH_SERVER_URL:-}
|
||||||
|
PUSH_SERVER_INSTANCE_ID: ${PUSH_SERVER_INSTANCE_ID:-}
|
||||||
|
PUSH_SERVER_SECRET: ${PUSH_SERVER_SECRET:-}
|
||||||
STIRLING_API_KEY: ${STIRLING_API_KEY}
|
STIRLING_API_KEY: ${STIRLING_API_KEY}
|
||||||
FEDEO_BOOTSTRAP_ADMIN_EMAIL: ${FEDEO_BOOTSTRAP_ADMIN_EMAIL:-}
|
FEDEO_BOOTSTRAP_ADMIN_EMAIL: ${FEDEO_BOOTSTRAP_ADMIN_EMAIL:-}
|
||||||
FEDEO_BOOTSTRAP_ADMIN_PASSWORD: ${FEDEO_BOOTSTRAP_ADMIN_PASSWORD:-}
|
FEDEO_BOOTSTRAP_ADMIN_PASSWORD: ${FEDEO_BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
|||||||
@@ -15,5 +15,12 @@ APNS_PRODUCTION=false
|
|||||||
ANDROID_SENDER_ID=
|
ANDROID_SENDER_ID=
|
||||||
FCM_PROJECT_ID=
|
FCM_PROJECT_ID=
|
||||||
FCM_SERVICE_ACCOUNT_JSON=
|
FCM_SERVICE_ACCOUNT_JSON=
|
||||||
|
|
||||||
|
# Zentral betriebene, abrechenbare Dienste
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
GOCARDLESS_BASE_URL=https://bankaccountdata.gocardless.com/api/v2
|
||||||
|
GOCARDLESS_SECRET_ID=
|
||||||
|
GOCARDLESS_SECRET_KEY=
|
||||||
NUXT_PUBLIC_API_BASE=http://localhost:4020
|
NUXT_PUBLIC_API_BASE=http://localhost:4020
|
||||||
NUXT_ADMIN_TOKEN=change-me-admin-token
|
NUXT_ADMIN_TOKEN=change-me-admin-token
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# FEDEO Push Server
|
# FEDEO Central Services
|
||||||
|
|
||||||
Eigenständiger Stack für den zentralen FEDEO Push-Transportdienst. Selfhosted FEDEO-Instanzen registrieren Geräte lokal und leiten nur technische Push-Aufträge an diesen Dienst weiter. Die Instanzen authentifizieren sich mit einem rotierbaren Schlüssel, der im Admin-Dashboard gepflegt wird.
|
Eigenständiger Stack für zentrale FEDEO-Dienste. Neben Push können Selfhost-Instanzen darüber freigeschaltete und verbrauchsabhängig erfasste KI- und Banking-Dienste verwenden. Die Instanzen authentifizieren sich mit einem rotierbaren HMAC-Schlüssel, der im Admin-Dashboard gepflegt wird.
|
||||||
|
|
||||||
## Bestandteile
|
## Bestandteile
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ Eigenständiger Stack für den zentralen FEDEO Push-Transportdienst. Selfhosted
|
|||||||
- `packages/db`: Drizzle Schema und Migrationen für PostgreSQL
|
- `packages/db`: Drizzle Schema und Migrationen für PostgreSQL
|
||||||
- `docker-compose.yml`: lokaler Stack aus Postgres, API und Admin
|
- `docker-compose.yml`: lokaler Stack aus Postgres, API und Admin
|
||||||
|
|
||||||
|
Provider-Schlüssel wie `OPENAI_API_KEY` und die GoCardless-Zugangsdaten liegen ausschließlich in diesem zentralen Stack. Selfhost-Instanzen erhalten nur Instanz-ID und Instanzschlüssel.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -20,6 +22,16 @@ npm run dev:api
|
|||||||
npm run dev:admin
|
npm run dev:admin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Für den dauerhaften Betrieb:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env mit sicheren Zugangsdaten und Provider-Schlüsseln befüllen
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Der API-Container wendet Datenbankmigrationen vor jedem Start automatisch an. PostgreSQL-Daten liegen im Volume `push_postgres_data`. Für ein öffentliches Deployment müssen API und Admin hinter einem TLS-Reverse-Proxy betrieben und `PUBLIC_API_BASE_URL` auf die öffentliche API-Adresse gesetzt werden.
|
||||||
|
|
||||||
Standardports:
|
Standardports:
|
||||||
|
|
||||||
- API: `http://localhost:4020`
|
- API: `http://localhost:4020`
|
||||||
@@ -70,6 +82,13 @@ const signature = createHmac("sha256", clientSecret).update(canonical).digest("h
|
|||||||
- `DELETE /v1/devices/:centralDeviceId`
|
- `DELETE /v1/devices/:centralDeviceId`
|
||||||
- `POST /v1/push`
|
- `POST /v1/push`
|
||||||
- `GET /v1/push/:deliveryJobId`
|
- `GET /v1/push/:deliveryJobId`
|
||||||
|
- `POST /v1/services/ai/chat-completions`
|
||||||
|
- `GET /v1/services/banking/institutions`
|
||||||
|
- `POST /v1/services/banking/requisitions`
|
||||||
|
- `GET /v1/services/banking/requisitions/:id`
|
||||||
|
- `GET /v1/services/banking/accounts/:id`
|
||||||
|
- `GET /v1/services/banking/accounts/:id/balances`
|
||||||
|
- `GET /v1/services/banking/accounts/:id/transactions`
|
||||||
|
|
||||||
Admin:
|
Admin:
|
||||||
|
|
||||||
@@ -80,6 +99,19 @@ Admin:
|
|||||||
- `POST /admin/instances/:id/rotate-secret`
|
- `POST /admin/instances/:id/rotate-secret`
|
||||||
- `GET /admin/instances/:id/devices`
|
- `GET /admin/instances/:id/devices`
|
||||||
- `GET /admin/instances/:id/jobs`
|
- `GET /admin/instances/:id/jobs`
|
||||||
|
- `GET /admin/instances/:id/services`
|
||||||
|
- `PUT /admin/instances/:id/services/:service`
|
||||||
|
- `GET /admin/instances/:id/usage`
|
||||||
|
- `GET /admin/usage/summary`
|
||||||
|
|
||||||
|
## Freischaltung und Abrechnung
|
||||||
|
|
||||||
|
Die Dienste `ai` und `banking` sind für neue Instanzen standardmäßig gesperrt. Im Instanzdetail des Admin-Dashboards werden sie einzeln aktiviert. Pro Dienst können ein Monatslimit und ein Preis in Mikro-Euro je Einheit gepflegt werden.
|
||||||
|
|
||||||
|
- KI wird anhand der von OpenAI gemeldeten Gesamt-Token erfasst.
|
||||||
|
- Banking wird je erfolgreichem Provider-Aufruf erfasst.
|
||||||
|
- Fehlgeschlagene Aufrufe werden technisch protokolliert, aber mit `0` Einheiten und `0` Kosten verbucht.
|
||||||
|
- Usage Events enthalten technische Metadaten, aber keine Prompts, Bankumsätze oder sonstigen fachlichen Nutzdaten.
|
||||||
|
|
||||||
## Apple Push Notification service
|
## Apple Push Notification service
|
||||||
|
|
||||||
@@ -106,6 +138,9 @@ Implementiert:
|
|||||||
- Zustelljobs mit Idempotenzschlüssel
|
- Zustelljobs mit Idempotenzschlüssel
|
||||||
- APNs-Zustellung für iOS
|
- APNs-Zustellung für iOS
|
||||||
- technische Status- und Fehlererfassung
|
- technische Status- und Fehlererfassung
|
||||||
|
- dienstbezogene Freischaltungen und Monatslimits
|
||||||
|
- unveränderliche Usage Events samt Kostenwert
|
||||||
|
- zentraler OpenAI- und GoCardless-Zugang
|
||||||
|
|
||||||
Vorbereitet, aber noch nicht vollständig implementiert:
|
Vorbereitet, aber noch nicht vollständig implementiert:
|
||||||
|
|
||||||
@@ -113,3 +148,4 @@ Vorbereitet, aber noch nicht vollständig implementiert:
|
|||||||
- FCM Zustellung
|
- FCM Zustellung
|
||||||
- asynchrone Queue/Worker-Verarbeitung
|
- asynchrone Queue/Worker-Verarbeitung
|
||||||
- produktive Rate-Limits pro Instanz
|
- produktive Rate-Limits pro Instanz
|
||||||
|
- Rechnungsstellung beziehungsweise Export an ein Buchhaltungssystem
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
type FetchOptions = {
|
type FetchOptions = {
|
||||||
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const { data, pending, refresh, error } = await useAsyncData("summary", () => pu
|
|||||||
instances: number;
|
instances: number;
|
||||||
devices: number;
|
devices: number;
|
||||||
jobs: number;
|
jobs: number;
|
||||||
failedJobs: number;
|
failedJobs: number;
|
||||||
|
serviceUnits: number;
|
||||||
}>("/admin/summary"), { immediate: false });
|
}>("/admin/summary"), { immediate: false });
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -26,12 +27,12 @@ watch(error, (nextError) => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-gray-950">Übersicht</h2>
|
<h2 class="text-2xl font-bold text-gray-950">Übersicht</h2>
|
||||||
<p class="text-sm text-gray-500">Technischer Zustand des zentralen Push-Gateways.</p>
|
<p class="text-sm text-gray-500">Technischer Zustand des zentralen FEDEO-Service-Gateways.</p>
|
||||||
</div>
|
</div>
|
||||||
<UButton icon="i-lucide-refresh-cw" variant="soft" :loading="pending" @click="refresh()">Aktualisieren</UButton>
|
<UButton icon="i-lucide-refresh-cw" variant="soft" :loading="pending" @click="refresh()">Aktualisieren</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-4">
|
<div class="grid gap-4 md:grid-cols-5">
|
||||||
<UCard>
|
<UCard>
|
||||||
<p class="text-sm text-gray-500">Instanzen</p>
|
<p class="text-sm text-gray-500">Instanzen</p>
|
||||||
<p class="mt-2 text-3xl font-bold">{{ data?.instances ?? 0 }}</p>
|
<p class="mt-2 text-3xl font-bold">{{ data?.instances ?? 0 }}</p>
|
||||||
@@ -48,6 +49,10 @@ watch(error, (nextError) => {
|
|||||||
<p class="text-sm text-gray-500">Fehler</p>
|
<p class="text-sm text-gray-500">Fehler</p>
|
||||||
<p class="mt-2 text-3xl font-bold text-red-600">{{ data?.failedJobs ?? 0 }}</p>
|
<p class="mt-2 text-3xl font-bold text-red-600">{{ data?.failedJobs ?? 0 }}</p>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
<UCard>
|
||||||
|
<p class="text-sm text-gray-500">Service-Einheiten</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold">{{ data?.serviceUnits ?? 0 }}</p>
|
||||||
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TokenGate>
|
</TokenGate>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const pushApi = usePushApi();
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const id = route.params.id as string;
|
const id = route.params.id as string;
|
||||||
const testSubmitting = ref(false);
|
const testSubmitting = ref(false);
|
||||||
|
const serviceSaving = ref("");
|
||||||
const selectedDeviceIds = ref<string[]>([]);
|
const selectedDeviceIds = ref<string[]>([]);
|
||||||
const testForm = reactive({
|
const testForm = reactive({
|
||||||
title: "FEDEO Push-Test",
|
title: "FEDEO Push-Test",
|
||||||
@@ -13,6 +14,12 @@ const testForm = reactive({
|
|||||||
const { data: instance, refresh: refreshInstance } = await useAsyncData(`instance-${id}`, () => pushApi.request<Record<string, any>>(`/admin/instances/${id}`), { immediate: false });
|
const { data: instance, refresh: refreshInstance } = await useAsyncData(`instance-${id}`, () => pushApi.request<Record<string, any>>(`/admin/instances/${id}`), { immediate: false });
|
||||||
const { data: devices, refresh: refreshDevices } = await useAsyncData(`devices-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/devices`), { default: () => [], immediate: false });
|
const { data: devices, refresh: refreshDevices } = await useAsyncData(`devices-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/devices`), { default: () => [], immediate: false });
|
||||||
const { data: jobs, refresh: refreshJobs } = await useAsyncData(`jobs-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/jobs`), { default: () => [], immediate: false });
|
const { data: jobs, refresh: refreshJobs } = await useAsyncData(`jobs-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/jobs`), { default: () => [], immediate: false });
|
||||||
|
const { data: entitlements, refresh: refreshEntitlements } = await useAsyncData(`services-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/services`), { default: () => [], immediate: false });
|
||||||
|
const { data: usage, refresh: refreshUsage } = await useAsyncData(`usage-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/usage?limit=100`), { default: () => [], immediate: false });
|
||||||
|
const serviceForms = reactive({
|
||||||
|
ai: { enabled: false, monthlyLimit: null as number | null, unitPriceMicros: 0 },
|
||||||
|
banking: { enabled: false, monthlyLimit: null as number | null, unitPriceMicros: 0 },
|
||||||
|
});
|
||||||
const activeDevices = computed(() => (devices.value || []).filter((device) => device.status === "active"));
|
const activeDevices = computed(() => (devices.value || []).filter((device) => device.status === "active"));
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -23,7 +30,30 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
await Promise.all([refreshInstance(), refreshDevices(), refreshJobs()]);
|
await Promise.all([refreshInstance(), refreshDevices(), refreshJobs(), refreshEntitlements(), refreshUsage()]);
|
||||||
|
for (const entitlement of entitlements.value || []) {
|
||||||
|
const form = serviceForms[entitlement.service as "ai" | "banking"];
|
||||||
|
if (form) Object.assign(form, { enabled: entitlement.enabled, monthlyLimit: entitlement.monthlyLimit, unitPriceMicros: entitlement.unitPriceMicros });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveService(service: "ai" | "banking") {
|
||||||
|
serviceSaving.value = service;
|
||||||
|
try {
|
||||||
|
const form = serviceForms[service];
|
||||||
|
await pushApi.request(`/admin/instances/${id}/services/${service}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: {
|
||||||
|
enabled: form.enabled,
|
||||||
|
monthlyLimit: Number(form.monthlyLimit) > 0 ? Number(form.monthlyLimit) : null,
|
||||||
|
unitPriceMicros: Math.max(0, Number(form.unitPriceMicros) || 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.all([refreshEntitlements(), refreshUsage()]);
|
||||||
|
toast.add({ title: `${service === "ai" ? "KI" : "Banking"}-Dienst gespeichert`, color: "success" });
|
||||||
|
} finally {
|
||||||
|
serviceSaving.value = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDevice(centralDeviceId: string, checked: boolean) {
|
function toggleDevice(centralDeviceId: string, checked: boolean) {
|
||||||
@@ -96,6 +126,39 @@ async function sendTestPush() {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header><h3 class="font-bold">Zentrale Dienste und Abrechnung</h3></template>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div v-for="service in (['ai', 'banking'] as const)" :key="service" class="space-y-3 rounded-lg border border-gray-200 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{{ service === 'ai' ? 'KI' : 'Banking' }}</p>
|
||||||
|
<p class="text-xs text-gray-500">{{ service === 'ai' ? 'Einheit: Provider-Token' : 'Einheit: Provider-Aufruf' }}</p>
|
||||||
|
</div>
|
||||||
|
<USwitch v-model="serviceForms[service].enabled" />
|
||||||
|
</div>
|
||||||
|
<UFormField label="Monatslimit (leer = unbegrenzt)">
|
||||||
|
<UInput v-model.number="serviceForms[service].monthlyLimit" type="number" min="1" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Preis pro Einheit in Mikro-Euro">
|
||||||
|
<UInput v-model.number="serviceForms[service].unitPriceMicros" type="number" min="0" />
|
||||||
|
</UFormField>
|
||||||
|
<UButton :loading="serviceSaving === service" @click="saveService(service)">Speichern</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="text-gray-500"><tr><th class="py-2">Zeit</th><th>Dienst</th><th>Vorgang</th><th>Status</th><th>Einheiten</th><th>Kosten</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="event in usage" :key="event.id" class="border-t border-gray-100">
|
||||||
|
<td class="py-2">{{ new Date(event.createdAt).toLocaleString() }}</td><td>{{ event.service }}</td><td>{{ event.operation }}</td><td>{{ event.status }}</td><td>{{ event.units }}</td><td>{{ (Number(event.costMicros || 0) / 1_000_000).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!usage?.length"><td colspan="6" class="py-4 text-gray-500">Noch keine Nutzung erfasst.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard>
|
<UCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ RUN npm run build --workspace @fedeo/push-db && npm run build --workspace @fedeo
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV DRIZZLE_MIGRATIONS_FOLDER=/app/packages/db/drizzle
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||||
COPY --from=build /app/packages/db/dist ./packages/db/dist
|
COPY --from=build /app/packages/db/dist ./packages/db/dist
|
||||||
|
COPY --from=build /app/packages/db/drizzle ./packages/db/drizzle
|
||||||
COPY --from=build /app/package.json ./package.json
|
COPY --from=build /app/package.json ./package.json
|
||||||
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
|
||||||
COPY --from=build /app/packages/db/package.json ./packages/db/package.json
|
COPY --from=build /app/packages/db/package.json ./packages/db/package.json
|
||||||
EXPOSE 4020
|
EXPOSE 4020
|
||||||
CMD ["node", "apps/api/dist/index.js"]
|
CMD ["sh", "-c", "node packages/db/dist/src/migrate.js && node apps/api/dist/index.js"]
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const envSchema = z.object({
|
|||||||
ANDROID_SENDER_ID: z.string().optional().default(""),
|
ANDROID_SENDER_ID: z.string().optional().default(""),
|
||||||
FCM_PROJECT_ID: z.string().optional().default(""),
|
FCM_PROJECT_ID: z.string().optional().default(""),
|
||||||
FCM_SERVICE_ACCOUNT_JSON: z.string().optional().default(""),
|
FCM_SERVICE_ACCOUNT_JSON: z.string().optional().default(""),
|
||||||
|
OPENAI_API_KEY: z.string().optional().default(""),
|
||||||
|
OPENAI_BASE_URL: z.string().url().default("https://api.openai.com/v1"),
|
||||||
|
GOCARDLESS_BASE_URL: z.string().url().default("https://bankaccountdata.gocardless.com/api/v2"),
|
||||||
|
GOCARDLESS_SECRET_ID: z.string().optional().default(""),
|
||||||
|
GOCARDLESS_SECRET_KEY: z.string().optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { pool } from "./db/client.js";
|
|||||||
import { adminRoutes } from "./routes/admin.js";
|
import { adminRoutes } from "./routes/admin.js";
|
||||||
import { instanceRoutes } from "./routes/instance.js";
|
import { instanceRoutes } from "./routes/instance.js";
|
||||||
import { publicRoutes } from "./routes/public.js";
|
import { publicRoutes } from "./routes/public.js";
|
||||||
|
import { serviceRoutes } from "./routes/services.js";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -39,6 +40,7 @@ await app.register(cors, {
|
|||||||
await app.register(publicRoutes);
|
await app.register(publicRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(instanceRoutes);
|
await app.register(instanceRoutes);
|
||||||
|
await app.register(serviceRoutes);
|
||||||
|
|
||||||
const close = async () => {
|
const close = async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auditLogs, deliveryJobs, pushDevices, pushInstances } from "@fedeo/push-db";
|
import { auditLogs, deliveryJobs, pushDevices, pushInstances, serviceEntitlements, serviceUsageEvents } from "@fedeo/push-db";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { requireAdmin } from "../lib/auth.js";
|
import { requireAdmin } from "../lib/auth.js";
|
||||||
import { encryptSecret } from "../lib/crypto.js";
|
import { encryptSecret } from "../lib/crypto.js";
|
||||||
@@ -29,6 +29,13 @@ const testPushSchema = z.object({
|
|||||||
priority: z.enum(["normal", "high"]).default("high"),
|
priority: z.enum(["normal", "high"]).default("high"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const entitlementSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
monthlyLimit: z.number().int().positive().nullable().default(null),
|
||||||
|
unitPriceMicros: z.number().int().nonnegative().default(0),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.addHook("preHandler", requireAdmin);
|
app.addHook("preHandler", requireAdmin);
|
||||||
|
|
||||||
@@ -37,11 +44,13 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const [devices] = await db.select({ count: sql<number>`count(*)::int` }).from(pushDevices);
|
const [devices] = await db.select({ count: sql<number>`count(*)::int` }).from(pushDevices);
|
||||||
const [jobs] = await db.select({ count: sql<number>`count(*)::int` }).from(deliveryJobs);
|
const [jobs] = await db.select({ count: sql<number>`count(*)::int` }).from(deliveryJobs);
|
||||||
const [failedJobs] = await db.select({ count: sql<number>`count(*)::int` }).from(deliveryJobs).where(eq(deliveryJobs.status, "failed"));
|
const [failedJobs] = await db.select({ count: sql<number>`count(*)::int` }).from(deliveryJobs).where(eq(deliveryJobs.status, "failed"));
|
||||||
|
const [usage] = await db.select({ units: sql<number>`coalesce(sum(${serviceUsageEvents.units}), 0)::bigint` }).from(serviceUsageEvents).where(eq(serviceUsageEvents.status, "succeeded"));
|
||||||
return {
|
return {
|
||||||
instances: instances?.count || 0,
|
instances: instances?.count || 0,
|
||||||
devices: devices?.count || 0,
|
devices: devices?.count || 0,
|
||||||
jobs: jobs?.count || 0,
|
jobs: jobs?.count || 0,
|
||||||
failedJobs: failedJobs?.count || 0,
|
failedJobs: failedJobs?.count || 0,
|
||||||
|
serviceUnits: Number(usage?.units || 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,6 +139,44 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return await db.select().from(deliveryJobs).where(eq(deliveryJobs.instanceId, params.id)).orderBy(desc(deliveryJobs.createdAt)).limit(100);
|
return await db.select().from(deliveryJobs).where(eq(deliveryJobs.instanceId, params.id)).orderBy(desc(deliveryJobs.createdAt)).limit(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/admin/instances/:id/services", async (request) => {
|
||||||
|
const params = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||||
|
return db.select().from(serviceEntitlements).where(eq(serviceEntitlements.instanceId, params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/admin/instances/:id/services/:service", async (request, reply) => {
|
||||||
|
const params = z.object({ id: z.string().uuid(), service: z.enum(["ai", "banking"]) }).parse(request.params);
|
||||||
|
const body = entitlementSchema.parse(request.body);
|
||||||
|
const [instance] = await db.select({ id: pushInstances.id }).from(pushInstances).where(eq(pushInstances.id, params.id)).limit(1);
|
||||||
|
if (!instance) return reply.code(404).send({ error: "instance_not_found" });
|
||||||
|
const [saved] = await db.insert(serviceEntitlements).values({
|
||||||
|
instanceId: params.id,
|
||||||
|
service: params.service,
|
||||||
|
...body,
|
||||||
|
}).onConflictDoUpdate({
|
||||||
|
target: [serviceEntitlements.instanceId, serviceEntitlements.service],
|
||||||
|
set: { ...body, updatedAt: new Date() },
|
||||||
|
}).returning();
|
||||||
|
await audit("admin", "instance.service.updated", params.id, { service: params.service, enabled: body.enabled, monthlyLimit: body.monthlyLimit });
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/instances/:id/usage", async (request) => {
|
||||||
|
const params = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||||
|
const query = z.object({ limit: z.coerce.number().int().positive().max(1000).default(200) }).parse(request.query);
|
||||||
|
return db.select().from(serviceUsageEvents).where(eq(serviceUsageEvents.instanceId, params.id)).orderBy(desc(serviceUsageEvents.createdAt)).limit(query.limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/usage/summary", async () => {
|
||||||
|
return db.select({
|
||||||
|
instanceId: serviceUsageEvents.instanceId,
|
||||||
|
service: serviceUsageEvents.service,
|
||||||
|
units: sql<number>`coalesce(sum(${serviceUsageEvents.units}), 0)::bigint`,
|
||||||
|
costMicros: sql<number>`coalesce(sum(${serviceUsageEvents.costMicros}), 0)::bigint`,
|
||||||
|
calls: sql<number>`count(*)::int`,
|
||||||
|
}).from(serviceUsageEvents).where(eq(serviceUsageEvents.status, "succeeded")).groupBy(serviceUsageEvents.instanceId, serviceUsageEvents.service);
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/admin/instances/:id/test-push", async (request, reply) => {
|
app.post("/admin/instances/:id/test-push", async (request, reply) => {
|
||||||
const params = z.object({ id: z.string().uuid() }).parse(request.params);
|
const params = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||||
const body = testPushSchema.parse(request.body || {});
|
const body = testPushSchema.parse(request.body || {});
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { env } from "../config/env.js";
|
|||||||
export async function publicRoutes(app: FastifyInstance): Promise<void> {
|
export async function publicRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get("/health", async () => ({
|
app.get("/health", async () => ({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
service: "fedeo-push-api",
|
service: "fedeo-central-services-api",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/v1/public-config", async () => ({
|
app.get("/v1/public-config", async () => ({
|
||||||
webPushPublicKey: env.WEB_PUSH_PUBLIC_KEY || null,
|
webPushPublicKey: env.WEB_PUSH_PUBLIC_KEY || null,
|
||||||
iosBundleId: env.IOS_BUNDLE_ID,
|
iosBundleId: env.IOS_BUNDLE_ID,
|
||||||
androidSenderId: env.ANDROID_SENDER_ID || null,
|
androidSenderId: env.ANDROID_SENDER_ID || null,
|
||||||
capabilities: ["ios_push", "minimal_payload", "instance_hmac"],
|
capabilities: ["ios_push", "minimal_payload", "instance_hmac", "service_entitlements", "usage_metering", "central_ai", "central_banking"],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
131
push-server/apps/api/src/routes/services.ts
Normal file
131
push-server/apps/api/src/routes/services.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { requireInstance } from "../lib/auth.js";
|
||||||
|
import { recordServiceUsage, requireCentralService } from "../services/central-services.js";
|
||||||
|
|
||||||
|
const aiSchema = z.object({
|
||||||
|
model: z.enum(["gpt-4o", "gpt-4o-mini"]),
|
||||||
|
messages: z.array(z.object({
|
||||||
|
role: z.enum(["system", "user", "assistant"]),
|
||||||
|
content: z.union([z.string(), z.array(z.unknown())]),
|
||||||
|
}).passthrough()).min(1).max(100),
|
||||||
|
response_format: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
max_tokens: z.number().int().positive().max(16_384).optional(),
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
const requisitionSchema = z.object({
|
||||||
|
institutionId: z.string().min(1).max(200),
|
||||||
|
redirect: z.string().url(),
|
||||||
|
userLanguage: z.string().length(2).default("de"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bankingToken: { access: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
async function providerJson(url: string, init: RequestInit) {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
const text = await response.text();
|
||||||
|
let data: any = null;
|
||||||
|
try { data = text ? JSON.parse(text) : null; } catch { data = { message: text }; }
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(data?.detail || data?.message || `Provider antwortete mit HTTP ${response.status}`) as Error & { code?: string; status?: number };
|
||||||
|
error.code = data?.code || `provider_http_${response.status}`;
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { data, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBankingToken() {
|
||||||
|
if (bankingToken && bankingToken.expiresAt > Date.now() + 60_000) return bankingToken.access;
|
||||||
|
if (!env.GOCARDLESS_SECRET_ID || !env.GOCARDLESS_SECRET_KEY) throw Object.assign(new Error("GoCardless ist zentral nicht konfiguriert."), { code: "banking_provider_not_configured" });
|
||||||
|
const { data } = await providerJson(`${env.GOCARDLESS_BASE_URL.replace(/\/$/, "")}/token/new/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ secret_id: env.GOCARDLESS_SECRET_ID, secret_key: env.GOCARDLESS_SECRET_KEY }),
|
||||||
|
});
|
||||||
|
bankingToken = { access: data.access, expiresAt: Date.now() + Number(data.access_expires || 86_400) * 1000 };
|
||||||
|
return bankingToken.access;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bankingRequest(path: string, init: RequestInit = {}) {
|
||||||
|
const access = await getBankingToken();
|
||||||
|
return providerJson(`${env.GOCARDLESS_BASE_URL.replace(/\/$/, "")}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { Accept: "application/json", Authorization: `Bearer ${access}`, ...(init.headers || {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeProviderError(error: any) {
|
||||||
|
return { error: error?.code || "provider_error", message: error?.message || "Zentraler Provider-Aufruf fehlgeschlagen." };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withBankingUsage(request: FastifyRequest, reply: any, operation: string, handler: () => Promise<{ data: any; response: Response }>) {
|
||||||
|
const entitlement = await requireCentralService(request, reply, "banking");
|
||||||
|
if (!entitlement) return;
|
||||||
|
try {
|
||||||
|
const result = await handler();
|
||||||
|
const requestId = await recordServiceUsage({ request, service: "banking", operation, status: "succeeded", unitPriceMicros: entitlement.unitPriceMicros, provider: "gocardless", providerRequestId: result.response.headers.get("x-request-id") });
|
||||||
|
reply.header("X-Fedeo-Request-Id", requestId);
|
||||||
|
return result.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
await recordServiceUsage({ request, service: "banking", operation, status: "failed", units: 0, unitPriceMicros: entitlement.unitPriceMicros, provider: "gocardless", errorCode: error?.code });
|
||||||
|
return reply.code(error?.status && error.status < 500 ? error.status : 502).send(safeProviderError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.addHook("preHandler", requireInstance);
|
||||||
|
|
||||||
|
app.post("/v1/services/ai/chat-completions", async (request, reply) => {
|
||||||
|
const entitlement = await requireCentralService(request, reply, "ai");
|
||||||
|
if (!entitlement) return;
|
||||||
|
if (!env.OPENAI_API_KEY) return reply.code(503).send({ error: "ai_provider_not_configured", message: "OpenAI ist zentral nicht konfiguriert." });
|
||||||
|
const body = aiSchema.parse(request.body);
|
||||||
|
try {
|
||||||
|
const { data, response } = await providerJson(`${env.OPENAI_BASE_URL.replace(/\/$/, "")}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.OPENAI_API_KEY}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const units = Number(data?.usage?.total_tokens || 0);
|
||||||
|
const requestId = await recordServiceUsage({ request, service: "ai", operation: "chat_completions", units, status: "succeeded", unitPriceMicros: entitlement.unitPriceMicros, provider: "openai", providerRequestId: response.headers.get("x-request-id"), metadata: { model: body.model, promptTokens: data?.usage?.prompt_tokens || 0, completionTokens: data?.usage?.completion_tokens || 0 } });
|
||||||
|
reply.header("X-Fedeo-Request-Id", requestId);
|
||||||
|
return data;
|
||||||
|
} catch (error: any) {
|
||||||
|
await recordServiceUsage({ request, service: "ai", operation: "chat_completions", units: 0, status: "failed", unitPriceMicros: entitlement.unitPriceMicros, provider: "openai", errorCode: error?.code, metadata: { model: body.model } });
|
||||||
|
return reply.code(error?.status && error.status < 500 ? error.status : 502).send(safeProviderError(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/services/banking/institutions", async (request, reply) => {
|
||||||
|
const query = z.object({ country: z.string().length(2).default("DE") }).parse(request.query);
|
||||||
|
return withBankingUsage(request, reply, "institutions", () => bankingRequest(`/institutions/?country=${encodeURIComponent(query.country.toLowerCase())}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/services/banking/requisitions", async (request, reply) => {
|
||||||
|
const body = requisitionSchema.parse(request.body);
|
||||||
|
if (new URL(body.redirect).origin !== new URL(request.pushInstance!.baseUrl).origin) {
|
||||||
|
return reply.code(400).send({ error: "redirect_origin_invalid", message: "Banking-Redirect muss zur registrierten Instanz gehören." });
|
||||||
|
}
|
||||||
|
return withBankingUsage(request, reply, "create_requisition", () => bankingRequest("/requisitions/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ redirect: body.redirect, institution_id: body.institutionId, user_language: body.userLanguage }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/services/banking/requisitions/:id", async (request, reply) => {
|
||||||
|
const { id } = z.object({ id: z.string().min(1).max(200) }).parse(request.params);
|
||||||
|
return withBankingUsage(request, reply, "get_requisition", () => bankingRequest(`/requisitions/${encodeURIComponent(id)}/`));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/services/banking/accounts/:id", async (request, reply) => {
|
||||||
|
const { id } = z.object({ id: z.string().min(1).max(200) }).parse(request.params);
|
||||||
|
return withBankingUsage(request, reply, "get_account", () => bankingRequest(`/accounts/${encodeURIComponent(id)}/`));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const resource of ["balances", "transactions"] as const) {
|
||||||
|
app.get(`/v1/services/banking/accounts/:id/${resource}`, async (request, reply) => {
|
||||||
|
const { id } = z.object({ id: z.string().min(1).max(200) }).parse(request.params);
|
||||||
|
return withBankingUsage(request, reply, `get_${resource}`, () => bankingRequest(`/accounts/${encodeURIComponent(id)}/${resource}/`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
84
push-server/apps/api/src/services/central-services.ts
Normal file
84
push-server/apps/api/src/services/central-services.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { and, eq, gte, sql } from "drizzle-orm";
|
||||||
|
import { serviceEntitlements, serviceUsageEvents } from "@fedeo/push-db";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
|
||||||
|
export type CentralServiceName = "ai" | "banking";
|
||||||
|
|
||||||
|
export async function requireCentralService(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
service: CentralServiceName,
|
||||||
|
) {
|
||||||
|
const instance = request.pushInstance!;
|
||||||
|
const [entitlement] = await db
|
||||||
|
.select()
|
||||||
|
.from(serviceEntitlements)
|
||||||
|
.where(and(eq(serviceEntitlements.instanceId, instance.id), eq(serviceEntitlements.service, service)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!entitlement?.enabled) {
|
||||||
|
await reply.code(403).send({
|
||||||
|
error: "service_not_enabled",
|
||||||
|
message: `Der zentrale Dienst '${service}' ist für diese Instanz nicht freigeschaltet.`,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entitlement.monthlyLimit !== null) {
|
||||||
|
const monthStart = new Date();
|
||||||
|
monthStart.setUTCDate(1);
|
||||||
|
monthStart.setUTCHours(0, 0, 0, 0);
|
||||||
|
const [usage] = await db
|
||||||
|
.select({ units: sql<number>`coalesce(sum(${serviceUsageEvents.units}), 0)::bigint` })
|
||||||
|
.from(serviceUsageEvents)
|
||||||
|
.where(and(
|
||||||
|
eq(serviceUsageEvents.instanceId, instance.id),
|
||||||
|
eq(serviceUsageEvents.service, service),
|
||||||
|
eq(serviceUsageEvents.status, "succeeded"),
|
||||||
|
gte(serviceUsageEvents.createdAt, monthStart),
|
||||||
|
));
|
||||||
|
if (Number(usage?.units || 0) >= entitlement.monthlyLimit) {
|
||||||
|
await reply.code(429).send({
|
||||||
|
error: "service_monthly_limit_reached",
|
||||||
|
message: `Das Monatslimit für den zentralen Dienst '${service}' ist erreicht.`,
|
||||||
|
limit: entitlement.monthlyLimit,
|
||||||
|
used: Number(usage?.units || 0),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entitlement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordServiceUsage(input: {
|
||||||
|
request: FastifyRequest;
|
||||||
|
service: CentralServiceName;
|
||||||
|
operation: string;
|
||||||
|
units?: number;
|
||||||
|
status: "succeeded" | "failed";
|
||||||
|
unitPriceMicros: number;
|
||||||
|
provider?: string;
|
||||||
|
providerRequestId?: string | null;
|
||||||
|
errorCode?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const requestId = String(input.request.headers["x-fedeo-request-id"] || randomUUID());
|
||||||
|
const units = Math.max(0, Math.trunc(input.units ?? 1));
|
||||||
|
await db.insert(serviceUsageEvents).values({
|
||||||
|
requestId,
|
||||||
|
instanceId: input.request.pushInstance!.id,
|
||||||
|
service: input.service,
|
||||||
|
operation: input.operation,
|
||||||
|
units,
|
||||||
|
costMicros: input.status === "succeeded" ? units * input.unitPriceMicros : 0,
|
||||||
|
status: input.status,
|
||||||
|
provider: input.provider,
|
||||||
|
providerRequestId: input.providerRequestId || null,
|
||||||
|
errorCode: input.errorCode,
|
||||||
|
metadata: input.metadata || {},
|
||||||
|
});
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: fedeo_push
|
POSTGRES_DB: fedeo_push
|
||||||
POSTGRES_USER: fedeo_push
|
POSTGRES_USER: fedeo_push
|
||||||
POSTGRES_PASSWORD: fedeo_push
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fedeo_push}
|
||||||
ports:
|
ports:
|
||||||
- "5442:5432"
|
- "5442:5432"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -16,29 +16,37 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/api/Dockerfile
|
dockerfile: apps/api/Dockerfile
|
||||||
env_file: .env
|
env_file: ${CENTRAL_ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://fedeo_push:fedeo_push@postgres:5432/fedeo_push
|
DATABASE_URL: postgres://fedeo_push:${POSTGRES_PASSWORD:-fedeo_push}@postgres:5432/fedeo_push
|
||||||
ports:
|
ports:
|
||||||
- "4020:4020"
|
- "4020:4020"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:4020/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/admin/Dockerfile
|
dockerfile: apps/admin/Dockerfile
|
||||||
env_file: .env
|
env_file: ${CENTRAL_ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
NUXT_PUBLIC_API_BASE: http://localhost:4020
|
NUXT_PUBLIC_API_BASE: ${PUBLIC_API_BASE_URL:-http://localhost:4020}
|
||||||
ports:
|
ports:
|
||||||
- "3020:3000"
|
- "3020:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
push_postgres_data:
|
push_postgres_data:
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
# Selfhost-Instanz anbinden
|
# Selfhost-Instanz an FEDEO Central Services anbinden
|
||||||
|
|
||||||
1. Instanz im Admin-Dashboard anlegen.
|
1. Instanz im Admin-Dashboard anlegen.
|
||||||
2. `instanceId` und den einmalig angezeigten `clientSecret` in der Selfhost-Instanz speichern.
|
2. `instanceId` und den einmalig angezeigten `clientSecret` in der Selfhost-Instanz speichern.
|
||||||
3. Selfhost-Instanz sendet regelmäßig `POST /v1/instances/heartbeat`.
|
3. Im Instanzdetail die benötigten Dienste `ai` und/oder `banking` freischalten und Limits festlegen.
|
||||||
4. Geräte werden über `POST /v1/devices` registriert.
|
4. Selfhost-Instanz sendet regelmäßig `POST /v1/instances/heartbeat`.
|
||||||
5. Push-Aufträge werden über `POST /v1/push` gesendet.
|
5. Geräte werden über `POST /v1/devices` registriert; weitere Dienste liegen unter `/v1/services`.
|
||||||
|
|
||||||
## Umgebungsvariablen der Selfhost-Instanz
|
## Umgebungsvariablen der Selfhost-Instanz
|
||||||
|
|
||||||
```env
|
```env
|
||||||
FEDEO_PUSH_MODE=central
|
FEDEO_CENTRAL_SERVICES_ENABLED=true
|
||||||
FEDEO_PUSH_GATEWAY_URL=https://push.fedeo.cloud
|
PUSH_SERVER_URL=https://services.fedeo.de
|
||||||
FEDEO_PUSH_INSTANCE_ID=inst_...
|
PUSH_SERVER_INSTANCE_ID=inst_...
|
||||||
FEDEO_PUSH_CLIENT_SECRET=fps_...
|
PUSH_SERVER_SECRET=fps_...
|
||||||
FEDEO_PUSH_PAYLOAD_MODE=minimal
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Wenn `FEDEO_CENTRAL_SERVICES_ENABLED=false` bleibt, verwendet FEDEO wie bisher direkt konfigurierte OpenAI- und GoCardless-Zugangsdaten. Damit bleibt ein vollständig unabhängiger Betrieb möglich.
|
||||||
|
|
||||||
## Minimaler Push-Auftrag
|
## Minimaler Push-Auftrag
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev --workspace @fedeo/push-api",
|
"dev": "npm run dev --workspace @fedeo/push-api",
|
||||||
"dev:api": "npm run dev --workspace @fedeo/push-api",
|
"dev:api": "npm run build --workspace @fedeo/push-db && npm run dev --workspace @fedeo/push-api",
|
||||||
"dev:admin": "npm run dev --workspace @fedeo/push-admin",
|
"dev:admin": "npm run dev --workspace @fedeo/push-admin",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
|
|||||||
36
push-server/packages/db/drizzle/0001_central_services.sql
Normal file
36
push-server/packages/db/drizzle/0001_central_services.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE TYPE "public"."central_service" AS ENUM('ai', 'banking');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."usage_status" AS ENUM('succeeded', 'failed');--> statement-breakpoint
|
||||||
|
CREATE TABLE "service_entitlements" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"instance_id" uuid NOT NULL,
|
||||||
|
"service" "central_service" NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT false NOT NULL,
|
||||||
|
"monthly_limit" bigint,
|
||||||
|
"unit_price_micros" bigint DEFAULT 0 NOT NULL,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "service_usage_events" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"request_id" text NOT NULL,
|
||||||
|
"instance_id" uuid NOT NULL,
|
||||||
|
"service" "central_service" NOT NULL,
|
||||||
|
"operation" text NOT NULL,
|
||||||
|
"units" bigint DEFAULT 1 NOT NULL,
|
||||||
|
"cost_micros" bigint DEFAULT 0 NOT NULL,
|
||||||
|
"status" "usage_status" NOT NULL,
|
||||||
|
"provider" text,
|
||||||
|
"provider_request_id" text,
|
||||||
|
"error_code" text,
|
||||||
|
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "service_usage_events_request_id_unique" UNIQUE("request_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "service_entitlements" ADD CONSTRAINT "service_entitlements_instance_id_push_instances_id_fk" FOREIGN KEY ("instance_id") REFERENCES "public"."push_instances"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "service_usage_events" ADD CONSTRAINT "service_usage_events_instance_id_push_instances_id_fk" FOREIGN KEY ("instance_id") REFERENCES "public"."push_instances"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "service_entitlements_instance_service_idx" ON "service_entitlements" USING btree ("instance_id","service");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "service_usage_events_request_id_idx" ON "service_usage_events" USING btree ("request_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "service_usage_events_instance_service_created_idx" ON "service_usage_events" USING btree ("instance_id","service","created_at");
|
||||||
1159
push-server/packages/db/drizzle/meta/0001_snapshot.json
Normal file
1159
push-server/packages/db/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1779461560095,
|
"when": 1779461560095,
|
||||||
"tag": "0000_big_devos",
|
"tag": "0000_big_devos",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785681122775,
|
||||||
|
"tag": "0001_central_services",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "dist/src/index.js",
|
||||||
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx src/migrate.ts",
|
"db:migrate": "tsx src/migrate.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import pg from "pg";
|
|||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL || "postgres://fedeo_push:fedeo_push@localhost:5442/fedeo_push";
|
const databaseUrl = process.env.DATABASE_URL || "postgres://fedeo_push:fedeo_push@localhost:5442/fedeo_push";
|
||||||
const migrationsFolder = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
|
const migrationsFolder = process.env.DRIZZLE_MIGRATIONS_FOLDER
|
||||||
|
? resolve(process.env.DRIZZLE_MIGRATIONS_FOLDER)
|
||||||
|
: resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: databaseUrl });
|
const pool = new Pool({ connectionString: databaseUrl });
|
||||||
const db = drizzle(pool);
|
const db = drizzle(pool);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
bigint,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
@@ -19,6 +20,8 @@ export const deviceStatus = pgEnum("device_status", ["active", "disabled", "inva
|
|||||||
export const deliveryStatus = pgEnum("delivery_status", ["accepted", "processing", "completed", "failed", "partial"]);
|
export const deliveryStatus = pgEnum("delivery_status", ["accepted", "processing", "completed", "failed", "partial"]);
|
||||||
export const attemptStatus = pgEnum("attempt_status", ["pending", "sent", "failed", "skipped"]);
|
export const attemptStatus = pgEnum("attempt_status", ["pending", "sent", "failed", "skipped"]);
|
||||||
export const attemptProvider = pgEnum("attempt_provider", ["web_push", "apns", "fcm"]);
|
export const attemptProvider = pgEnum("attempt_provider", ["web_push", "apns", "fcm"]);
|
||||||
|
export const centralService = pgEnum("central_service", ["ai", "banking"]);
|
||||||
|
export const usageStatus = pgEnum("usage_status", ["succeeded", "failed"]);
|
||||||
|
|
||||||
export const pushInstances = pgTable(
|
export const pushInstances = pgTable(
|
||||||
"push_instances",
|
"push_instances",
|
||||||
@@ -144,6 +147,51 @@ export const auditLogs = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const serviceEntitlements = pgTable(
|
||||||
|
"service_entitlements",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
instanceId: uuid("instance_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => pushInstances.id, { onDelete: "cascade" }),
|
||||||
|
service: centralService("service").notNull(),
|
||||||
|
enabled: boolean("enabled").notNull().default(false),
|
||||||
|
monthlyLimit: bigint("monthly_limit", { mode: "number" }),
|
||||||
|
unitPriceMicros: bigint("unit_price_micros", { mode: "number" }).notNull().default(0),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
instanceServiceIdx: uniqueIndex("service_entitlements_instance_service_idx").on(table.instanceId, table.service),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const serviceUsageEvents = pgTable(
|
||||||
|
"service_usage_events",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
requestId: text("request_id").notNull().unique(),
|
||||||
|
instanceId: uuid("instance_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => pushInstances.id, { onDelete: "cascade" }),
|
||||||
|
service: centralService("service").notNull(),
|
||||||
|
operation: text("operation").notNull(),
|
||||||
|
units: bigint("units", { mode: "number" }).notNull().default(1),
|
||||||
|
costMicros: bigint("cost_micros", { mode: "number" }).notNull().default(0),
|
||||||
|
status: usageStatus("status").notNull(),
|
||||||
|
provider: text("provider"),
|
||||||
|
providerRequestId: text("provider_request_id"),
|
||||||
|
errorCode: text("error_code"),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
requestIdIdx: uniqueIndex("service_usage_events_request_id_idx").on(table.requestId),
|
||||||
|
instanceServiceCreatedIdx: index("service_usage_events_instance_service_created_idx").on(table.instanceId, table.service, table.createdAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export type PushInstance = typeof pushInstances.$inferSelect;
|
export type PushInstance = typeof pushInstances.$inferSelect;
|
||||||
export type NewPushInstance = typeof pushInstances.$inferInsert;
|
export type NewPushInstance = typeof pushInstances.$inferInsert;
|
||||||
export type PushDevice = typeof pushDevices.$inferSelect;
|
export type PushDevice = typeof pushDevices.$inferSelect;
|
||||||
@@ -152,3 +200,5 @@ export type DeliveryJob = typeof deliveryJobs.$inferSelect;
|
|||||||
export type NewDeliveryJob = typeof deliveryJobs.$inferInsert;
|
export type NewDeliveryJob = typeof deliveryJobs.$inferInsert;
|
||||||
export type DeliveryAttempt = typeof deliveryAttempts.$inferSelect;
|
export type DeliveryAttempt = typeof deliveryAttempts.$inferSelect;
|
||||||
export type NewDeliveryAttempt = typeof deliveryAttempts.$inferInsert;
|
export type NewDeliveryAttempt = typeof deliveryAttempts.$inferInsert;
|
||||||
|
export type ServiceEntitlement = typeof serviceEntitlements.$inferSelect;
|
||||||
|
export type ServiceUsageEvent = typeof serviceUsageEvents.$inferSelect;
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ write_env() {
|
|||||||
local dokubox_secure="${34}"
|
local dokubox_secure="${34}"
|
||||||
local dokubox_user="${35}"
|
local dokubox_user="${35}"
|
||||||
local dokubox_password="${36}"
|
local dokubox_password="${36}"
|
||||||
|
local central_services_enabled="${37}"
|
||||||
|
local central_services_url="${38}"
|
||||||
|
local central_instance_id="${39}"
|
||||||
|
local central_instance_secret="${40}"
|
||||||
|
|
||||||
cat >"$ENV_FILE" <<EOF
|
cat >"$ENV_FILE" <<EOF
|
||||||
# FEDEO Selfhosting
|
# FEDEO Selfhosting
|
||||||
@@ -384,6 +388,10 @@ DOKUBOX_IMAP_PASSWORD=$(env_quote "$dokubox_password")
|
|||||||
|
|
||||||
OPENAI_API_KEY=$(env_quote "$openai_key")
|
OPENAI_API_KEY=$(env_quote "$openai_key")
|
||||||
STIRLING_API_KEY=$(env_quote "$stirling_key")
|
STIRLING_API_KEY=$(env_quote "$stirling_key")
|
||||||
|
FEDEO_CENTRAL_SERVICES_ENABLED=$(env_quote "$central_services_enabled")
|
||||||
|
PUSH_SERVER_URL=$(env_quote "$central_services_url")
|
||||||
|
PUSH_SERVER_INSTANCE_ID=$(env_quote "$central_instance_id")
|
||||||
|
PUSH_SERVER_SECRET=$(env_quote "$central_instance_secret")
|
||||||
NUXT_PUBLIC_PDF_LICENSE=$(env_quote "$pdf_license")
|
NUXT_PUBLIC_PDF_LICENSE=$(env_quote "$pdf_license")
|
||||||
NODE_EXPORTER_URL=$(env_quote "http://node-exporter:9100")
|
NODE_EXPORTER_URL=$(env_quote "http://node-exporter:9100")
|
||||||
|
|
||||||
@@ -561,6 +569,10 @@ main() {
|
|||||||
local dokubox_secure="true"
|
local dokubox_secure="true"
|
||||||
local dokubox_user="dokubox@example.com"
|
local dokubox_user="dokubox@example.com"
|
||||||
local dokubox_password="change-this-imap-password"
|
local dokubox_password="change-this-imap-password"
|
||||||
|
local central_services_enabled="false"
|
||||||
|
local central_services_url="https://services.fedeo.de"
|
||||||
|
local central_instance_id=""
|
||||||
|
local central_instance_secret=""
|
||||||
|
|
||||||
if [[ "$MODE" == "advanced" ]]; then
|
if [[ "$MODE" == "advanced" ]]; then
|
||||||
echo
|
echo
|
||||||
@@ -584,6 +596,12 @@ main() {
|
|||||||
echo "Advanced: Banking und Dokubox"
|
echo "Advanced: Banking und Dokubox"
|
||||||
gocardless_secret_id="$(prompt "GoCardless Secret ID" "$gocardless_secret_id")"
|
gocardless_secret_id="$(prompt "GoCardless Secret ID" "$gocardless_secret_id")"
|
||||||
gocardless_secret_key="$(prompt_secret "GoCardless Secret Key" "$gocardless_secret_key")"
|
gocardless_secret_key="$(prompt_secret "GoCardless Secret Key" "$gocardless_secret_key")"
|
||||||
|
central_services_enabled="$(prompt "Zentrale FEDEO-Dienste verwenden true/false" "$central_services_enabled")"
|
||||||
|
if [[ "$central_services_enabled" == "true" ]]; then
|
||||||
|
central_services_url="$(prompt "Zentrale FEDEO-Service-URL" "$central_services_url")"
|
||||||
|
central_instance_id="$(prompt "FEDEO Instanz-ID" "$central_instance_id")"
|
||||||
|
central_instance_secret="$(prompt_secret "FEDEO Instanzschlüssel" "$central_instance_secret")"
|
||||||
|
fi
|
||||||
dokubox_host="$(prompt "Dokubox IMAP Host" "$dokubox_host")"
|
dokubox_host="$(prompt "Dokubox IMAP Host" "$dokubox_host")"
|
||||||
dokubox_port="$(prompt "Dokubox IMAP Port" "$dokubox_port")"
|
dokubox_port="$(prompt "Dokubox IMAP Port" "$dokubox_port")"
|
||||||
dokubox_secure="$(prompt "Dokubox IMAP Secure true/false" "$dokubox_secure")"
|
dokubox_secure="$(prompt "Dokubox IMAP Secure true/false" "$dokubox_secure")"
|
||||||
@@ -600,7 +618,9 @@ main() {
|
|||||||
"$mailer_ssl" "$mailer_user" "$mailer_pass" "$mailer_from" "$web_push_public" \
|
"$mailer_ssl" "$mailer_user" "$mailer_pass" "$mailer_from" "$web_push_public" \
|
||||||
"$web_push_private" "$pdf_license" "$openai_key" "$stirling_key" \
|
"$web_push_private" "$pdf_license" "$openai_key" "$stirling_key" \
|
||||||
"$gocardless_secret_id" "$gocardless_secret_key" "$dokubox_host" \
|
"$gocardless_secret_id" "$gocardless_secret_key" "$dokubox_host" \
|
||||||
"$dokubox_port" "$dokubox_secure" "$dokubox_user" "$dokubox_password"
|
"$dokubox_port" "$dokubox_secure" "$dokubox_user" "$dokubox_password" \
|
||||||
|
"$central_services_enabled" "$central_services_url" "$central_instance_id" \
|
||||||
|
"$central_instance_secret"
|
||||||
|
|
||||||
prepare_directories
|
prepare_directories
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user