@frihet/mcp-server 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/canary/mcp-harness.d.ts +1 -1
- package/dist/canary/mcp-harness.js +3 -2
- package/dist/claude-profile.js +3 -1
- package/dist/client-interface.d.ts +7 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +16 -0
- package/dist/demo-client.d.ts +14 -16
- package/dist/demo-client.js +101 -30
- package/dist/demo-fixtures.d.ts +14 -4
- package/dist/demo-fixtures.js +84 -16
- package/dist/invoice-payment-link.d.ts +189 -0
- package/dist/invoice-payment-link.js +371 -0
- package/dist/mpp-sandbox.d.ts +176 -0
- package/dist/mpp-sandbox.js +612 -0
- package/dist/openai-103/openai-expansion-103.d.ts +1192 -0
- package/dist/openai-103/openai-expansion-103.js +1628 -0
- package/dist/openai-profile.d.ts +5 -1
- package/dist/openai-profile.js +14 -2
- package/dist/openai-review-contract.js +2 -1
- package/dist/server-composition.js +34 -0
- package/dist/tool-exposure.js +2 -1
- package/dist/tools/purchaseOrders.d.ts +30 -0
- package/dist/tools/purchaseOrders.js +102 -0
- package/dist/tools/register-all.js +2 -0
- package/dist/tools/shared.d.ts +42 -0
- package/dist/tools/shared.js +46 -0
- package/docs/agent-onboarding.json +2 -2
- package/package.json +9 -9
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dedicated Invoice Portal Payment Link adapter & MCP tool (#3161).
|
|
3
|
+
*
|
|
4
|
+
* Implements the MCP/SDK contract for `berthelius/Frihet-ERP#3161`:
|
|
5
|
+
* "Dame el enlace para cobrar esta factura y dime qué queda pendiente"
|
|
6
|
+
*
|
|
7
|
+
* Architectural & Security Invariants:
|
|
8
|
+
* 1. Single Authority: consumes server-computed Payment Authority V1 / portal
|
|
9
|
+
* state (`computePortalUrlV1` / `generatePortalToken`). Never computes
|
|
10
|
+
* balances by summing legacy client fields, and never coerces an unknown
|
|
11
|
+
* or degraded balance (`null`) to `0`.
|
|
12
|
+
* 2. Narrow Redaction Exception: generic invoice tools (`get_invoice`,
|
|
13
|
+
* `list_invoices`, `search_invoices`, `send_invoice`) and observability
|
|
14
|
+
* traces continue to strip `paymentUrl` / `paymentLink` via `src/redaction.ts`.
|
|
15
|
+
* Only `get_invoice_payment_link` projects `authorizedPortalUrl` in its
|
|
16
|
+
* dedicated `outputSchema`, and only after passing `validatePortalPayUrl()`.
|
|
17
|
+
* 3. No Implicit Side Effects: retrieving a portal link never sends an email,
|
|
18
|
+
* never issues a draft invoice, never creates a Stripe PaymentIntent or
|
|
19
|
+
* Checkout Session reservation, never initiates a charge, and never marks
|
|
20
|
+
* an invoice paid or reconciled.
|
|
21
|
+
* 4. Honest Token Lifetime Disclosure: Frihet HMAC `invoice:v1` portal tokens
|
|
22
|
+
* are stateless HMAC capability links whose collectibility and remaining
|
|
23
|
+
* balance are revalidated server-side on every open and payment attempt.
|
|
24
|
+
*/
|
|
25
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
|
+
import { z } from "zod/v4";
|
|
27
|
+
export declare const CANONICAL_PORTAL_ORIGIN = "https://app.frihet.io";
|
|
28
|
+
export declare const INVOICE_PORTAL_STATUSES: readonly ["issued", "sent", "overdue", "partially_paid", "draft", "paid", "cancelled", "unknown"];
|
|
29
|
+
export type InvoicePortalStatus = (typeof INVOICE_PORTAL_STATUSES)[number];
|
|
30
|
+
export declare const PORTAL_UNAVAILABLE_REASONS: readonly ["draft_invoice", "already_paid", "cancelled_invoice", "unknown_balance", "connect_not_ready", "unsupported_currency", "portal_disabled", "permission_denied", "tenant_mismatch", "invalid_portal_url", "not_collectible"];
|
|
31
|
+
export type PortalUnavailableReason = (typeof PORTAL_UNAVAILABLE_REASONS)[number];
|
|
32
|
+
export declare const BALANCE_PROVENANCE_VALUES: readonly ["payment_authority_v1", "server_computed", "unknown_degraded"];
|
|
33
|
+
export type BalanceProvenance = (typeof BALANCE_PROVENANCE_VALUES)[number];
|
|
34
|
+
export interface UpstreamInvoicePaymentLinkResponse {
|
|
35
|
+
invoiceId: string;
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
documentNumber?: string | null;
|
|
38
|
+
status: InvoicePortalStatus;
|
|
39
|
+
collectible: boolean;
|
|
40
|
+
unavailableReason?: PortalUnavailableReason | null;
|
|
41
|
+
currency: string;
|
|
42
|
+
amountRemainingMinor: number | null;
|
|
43
|
+
totalAmountMinor?: number | null;
|
|
44
|
+
paidAmountMinor?: number | null;
|
|
45
|
+
balanceProvenance: BalanceProvenance;
|
|
46
|
+
asOf?: string;
|
|
47
|
+
portalUrl?: string | null;
|
|
48
|
+
paymentUrl?: string | null;
|
|
49
|
+
}
|
|
50
|
+
export interface InvoicePaymentLinkTransport {
|
|
51
|
+
requestInvoicePaymentLink(invoiceId: string, options?: {
|
|
52
|
+
idempotencyKey?: string;
|
|
53
|
+
}): Promise<UpstreamInvoicePaymentLinkResponse>;
|
|
54
|
+
}
|
|
55
|
+
export declare const invoicePaymentLinkOutputShape: {
|
|
56
|
+
invoiceId: z.ZodString;
|
|
57
|
+
workspaceId: z.ZodString;
|
|
58
|
+
status: z.ZodEnum<{
|
|
59
|
+
cancelled: "cancelled";
|
|
60
|
+
paid: "paid";
|
|
61
|
+
sent: "sent";
|
|
62
|
+
draft: "draft";
|
|
63
|
+
overdue: "overdue";
|
|
64
|
+
unknown: "unknown";
|
|
65
|
+
issued: "issued";
|
|
66
|
+
partially_paid: "partially_paid";
|
|
67
|
+
}>;
|
|
68
|
+
collectible: z.ZodBoolean;
|
|
69
|
+
unavailableReason: z.ZodNullable<z.ZodEnum<{
|
|
70
|
+
draft_invoice: "draft_invoice";
|
|
71
|
+
already_paid: "already_paid";
|
|
72
|
+
cancelled_invoice: "cancelled_invoice";
|
|
73
|
+
unknown_balance: "unknown_balance";
|
|
74
|
+
connect_not_ready: "connect_not_ready";
|
|
75
|
+
unsupported_currency: "unsupported_currency";
|
|
76
|
+
portal_disabled: "portal_disabled";
|
|
77
|
+
permission_denied: "permission_denied";
|
|
78
|
+
tenant_mismatch: "tenant_mismatch";
|
|
79
|
+
invalid_portal_url: "invalid_portal_url";
|
|
80
|
+
not_collectible: "not_collectible";
|
|
81
|
+
}>>;
|
|
82
|
+
currency: z.ZodString;
|
|
83
|
+
amountRemainingMinor: z.ZodNullable<z.ZodNumber>;
|
|
84
|
+
amountRemainingFormatted: z.ZodNullable<z.ZodString>;
|
|
85
|
+
balanceProvenance: z.ZodEnum<{
|
|
86
|
+
payment_authority_v1: "payment_authority_v1";
|
|
87
|
+
server_computed: "server_computed";
|
|
88
|
+
unknown_degraded: "unknown_degraded";
|
|
89
|
+
}>;
|
|
90
|
+
asOf: z.ZodString;
|
|
91
|
+
authorizedPortalUrl: z.ZodNullable<z.ZodString>;
|
|
92
|
+
tokenLifetimePolicy: z.ZodLiteral<"hmac_v1_stateless_revalidated_on_open">;
|
|
93
|
+
sideEffects: z.ZodObject<{
|
|
94
|
+
emailSent: z.ZodLiteral<false>;
|
|
95
|
+
paymentInitiated: z.ZodLiteral<false>;
|
|
96
|
+
ledgerMutated: z.ZodLiteral<false>;
|
|
97
|
+
checkoutSessionCreated: z.ZodLiteral<false>;
|
|
98
|
+
}, z.core.$strict>;
|
|
99
|
+
};
|
|
100
|
+
export declare const InvoicePaymentLinkOutputSchema: z.ZodObject<{
|
|
101
|
+
invoiceId: z.ZodString;
|
|
102
|
+
workspaceId: z.ZodString;
|
|
103
|
+
status: z.ZodEnum<{
|
|
104
|
+
cancelled: "cancelled";
|
|
105
|
+
paid: "paid";
|
|
106
|
+
sent: "sent";
|
|
107
|
+
draft: "draft";
|
|
108
|
+
overdue: "overdue";
|
|
109
|
+
unknown: "unknown";
|
|
110
|
+
issued: "issued";
|
|
111
|
+
partially_paid: "partially_paid";
|
|
112
|
+
}>;
|
|
113
|
+
collectible: z.ZodBoolean;
|
|
114
|
+
unavailableReason: z.ZodNullable<z.ZodEnum<{
|
|
115
|
+
draft_invoice: "draft_invoice";
|
|
116
|
+
already_paid: "already_paid";
|
|
117
|
+
cancelled_invoice: "cancelled_invoice";
|
|
118
|
+
unknown_balance: "unknown_balance";
|
|
119
|
+
connect_not_ready: "connect_not_ready";
|
|
120
|
+
unsupported_currency: "unsupported_currency";
|
|
121
|
+
portal_disabled: "portal_disabled";
|
|
122
|
+
permission_denied: "permission_denied";
|
|
123
|
+
tenant_mismatch: "tenant_mismatch";
|
|
124
|
+
invalid_portal_url: "invalid_portal_url";
|
|
125
|
+
not_collectible: "not_collectible";
|
|
126
|
+
}>>;
|
|
127
|
+
currency: z.ZodString;
|
|
128
|
+
amountRemainingMinor: z.ZodNullable<z.ZodNumber>;
|
|
129
|
+
amountRemainingFormatted: z.ZodNullable<z.ZodString>;
|
|
130
|
+
balanceProvenance: z.ZodEnum<{
|
|
131
|
+
payment_authority_v1: "payment_authority_v1";
|
|
132
|
+
server_computed: "server_computed";
|
|
133
|
+
unknown_degraded: "unknown_degraded";
|
|
134
|
+
}>;
|
|
135
|
+
asOf: z.ZodString;
|
|
136
|
+
authorizedPortalUrl: z.ZodNullable<z.ZodString>;
|
|
137
|
+
tokenLifetimePolicy: z.ZodLiteral<"hmac_v1_stateless_revalidated_on_open">;
|
|
138
|
+
sideEffects: z.ZodObject<{
|
|
139
|
+
emailSent: z.ZodLiteral<false>;
|
|
140
|
+
paymentInitiated: z.ZodLiteral<false>;
|
|
141
|
+
ledgerMutated: z.ZodLiteral<false>;
|
|
142
|
+
checkoutSessionCreated: z.ZodLiteral<false>;
|
|
143
|
+
}, z.core.$strict>;
|
|
144
|
+
}, z.core.$strict>;
|
|
145
|
+
export type InvoicePaymentLinkOutput = z.infer<typeof InvoicePaymentLinkOutputSchema>;
|
|
146
|
+
export declare function assertSafePathSegment(value: string, label: string): string;
|
|
147
|
+
/**
|
|
148
|
+
* Validates that a candidate URL is strictly a canonical Frihet invoice portal
|
|
149
|
+
* URL (`https://app.frihet.io/pay/{workspaceId}/{invoiceId}/{token}`) bound to
|
|
150
|
+
* the exact `expectedWorkspaceId` and `expectedInvoiceId`, with no query
|
|
151
|
+
* parameters, fragments, credentials, or alternate origins.
|
|
152
|
+
*/
|
|
153
|
+
export declare function validatePortalPayUrl(rawUrl: unknown, expectedWorkspaceId: string, expectedInvoiceId: string): {
|
|
154
|
+
valid: true;
|
|
155
|
+
url: string;
|
|
156
|
+
maskedUrl: string;
|
|
157
|
+
} | {
|
|
158
|
+
valid: false;
|
|
159
|
+
reason: string;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Evaluates and normalizes the upstream server response into the fail-closed
|
|
163
|
+
* `InvoicePaymentLinkOutput` contract.
|
|
164
|
+
*/
|
|
165
|
+
export declare function projectInvoicePaymentLinkOutput(upstream: UpstreamInvoicePaymentLinkResponse, expectedWorkspaceId?: string): InvoicePaymentLinkOutput;
|
|
166
|
+
/**
|
|
167
|
+
* Redacts a projected `InvoicePaymentLinkOutput` for logs/traces so capability
|
|
168
|
+
* URLs never leak into Langfuse or generic stderr logs.
|
|
169
|
+
*/
|
|
170
|
+
export declare function redactPaymentLinkForTrace(output: InvoicePaymentLinkOutput): Record<string, unknown>;
|
|
171
|
+
/**
|
|
172
|
+
* Verifies that generic invoice payloads continue to have `paymentUrl` and
|
|
173
|
+
* `paymentLink` stripped in-place by `deepRedact`.
|
|
174
|
+
*/
|
|
175
|
+
export declare function enforceGenericInvoiceRedaction<T extends Record<string, unknown>>(record: T): T;
|
|
176
|
+
/**
|
|
177
|
+
* In-memory idempotency coordinator for `get_invoice_payment_link` retries.
|
|
178
|
+
* Ensures that retrying with the same `(idempotencyKey, invoiceId)` converges
|
|
179
|
+
* on the same response, while reusing the same `idempotencyKey` for a DIFFERENT
|
|
180
|
+
* `invoiceId` fails closed with `IDEMPOTENCY_KEY_CONFLICT`.
|
|
181
|
+
*/
|
|
182
|
+
export declare class PaymentLinkIdempotencyCoordinator {
|
|
183
|
+
private readonly records;
|
|
184
|
+
execute(invoiceId: string, idempotencyKey: string | undefined, runner: () => Promise<InvoicePaymentLinkOutput>): Promise<InvoicePaymentLinkOutput>;
|
|
185
|
+
}
|
|
186
|
+
export declare function registerInvoicePaymentLinkTool(server: McpServer, transport: InvoicePaymentLinkTransport, options?: {
|
|
187
|
+
expectedWorkspaceId?: string;
|
|
188
|
+
}): void;
|
|
189
|
+
//# sourceMappingURL=invoice-payment-link.d.ts.map
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dedicated Invoice Portal Payment Link adapter & MCP tool (#3161).
|
|
3
|
+
*
|
|
4
|
+
* Implements the MCP/SDK contract for `berthelius/Frihet-ERP#3161`:
|
|
5
|
+
* "Dame el enlace para cobrar esta factura y dime qué queda pendiente"
|
|
6
|
+
*
|
|
7
|
+
* Architectural & Security Invariants:
|
|
8
|
+
* 1. Single Authority: consumes server-computed Payment Authority V1 / portal
|
|
9
|
+
* state (`computePortalUrlV1` / `generatePortalToken`). Never computes
|
|
10
|
+
* balances by summing legacy client fields, and never coerces an unknown
|
|
11
|
+
* or degraded balance (`null`) to `0`.
|
|
12
|
+
* 2. Narrow Redaction Exception: generic invoice tools (`get_invoice`,
|
|
13
|
+
* `list_invoices`, `search_invoices`, `send_invoice`) and observability
|
|
14
|
+
* traces continue to strip `paymentUrl` / `paymentLink` via `src/redaction.ts`.
|
|
15
|
+
* Only `get_invoice_payment_link` projects `authorizedPortalUrl` in its
|
|
16
|
+
* dedicated `outputSchema`, and only after passing `validatePortalPayUrl()`.
|
|
17
|
+
* 3. No Implicit Side Effects: retrieving a portal link never sends an email,
|
|
18
|
+
* never issues a draft invoice, never creates a Stripe PaymentIntent or
|
|
19
|
+
* Checkout Session reservation, never initiates a charge, and never marks
|
|
20
|
+
* an invoice paid or reconciled.
|
|
21
|
+
* 4. Honest Token Lifetime Disclosure: Frihet HMAC `invoice:v1` portal tokens
|
|
22
|
+
* are stateless HMAC capability links whose collectibility and remaining
|
|
23
|
+
* balance are revalidated server-side on every open and payment attempt.
|
|
24
|
+
*/
|
|
25
|
+
import { z } from "zod/v4";
|
|
26
|
+
import { deepRedact, redactClone, SENSITIVE_FIELD_NAMES } from "./redaction.js";
|
|
27
|
+
import { READ_ONLY_ANNOTATIONS, withToolLogging } from "./tools/shared.js";
|
|
28
|
+
export const CANONICAL_PORTAL_ORIGIN = "https://app.frihet.io";
|
|
29
|
+
const SAFE_PATH_SEGMENT_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
30
|
+
const SAFE_HMAC_TOKEN_RE = /^[A-Za-z0-9._-]{16,256}$/;
|
|
31
|
+
const ISO_CURRENCY_RE = /^[A-Z]{3}$/;
|
|
32
|
+
export const INVOICE_PORTAL_STATUSES = [
|
|
33
|
+
"issued",
|
|
34
|
+
"sent",
|
|
35
|
+
"overdue",
|
|
36
|
+
"partially_paid",
|
|
37
|
+
"draft",
|
|
38
|
+
"paid",
|
|
39
|
+
"cancelled",
|
|
40
|
+
"unknown",
|
|
41
|
+
];
|
|
42
|
+
export const PORTAL_UNAVAILABLE_REASONS = [
|
|
43
|
+
"draft_invoice",
|
|
44
|
+
"already_paid",
|
|
45
|
+
"cancelled_invoice",
|
|
46
|
+
"unknown_balance",
|
|
47
|
+
"connect_not_ready",
|
|
48
|
+
"unsupported_currency",
|
|
49
|
+
"portal_disabled",
|
|
50
|
+
"permission_denied",
|
|
51
|
+
"tenant_mismatch",
|
|
52
|
+
"invalid_portal_url",
|
|
53
|
+
"not_collectible",
|
|
54
|
+
];
|
|
55
|
+
export const BALANCE_PROVENANCE_VALUES = [
|
|
56
|
+
"payment_authority_v1",
|
|
57
|
+
"server_computed",
|
|
58
|
+
"unknown_degraded",
|
|
59
|
+
];
|
|
60
|
+
export const invoicePaymentLinkOutputShape = {
|
|
61
|
+
invoiceId: z.string().describe("Invoice record ID / ID de la factura"),
|
|
62
|
+
workspaceId: z.string().describe("Authenticated workspace ID / ID del workspace"),
|
|
63
|
+
status: z.enum(INVOICE_PORTAL_STATUSES).describe("Server-reported invoice lifecycle status / Estado de la factura"),
|
|
64
|
+
collectible: z.boolean().describe("Whether the invoice can currently be paid through the portal / Si la factura es cobrable actualmente en el portal"),
|
|
65
|
+
unavailableReason: z
|
|
66
|
+
.enum(PORTAL_UNAVAILABLE_REASONS)
|
|
67
|
+
.nullable()
|
|
68
|
+
.describe("Reason why the payment link is unavailable when collectible=false / Motivo de no disponibilidad"),
|
|
69
|
+
currency: z.string().describe("ISO 4217 3-letter currency code / Código de divisa ISO 4217"),
|
|
70
|
+
amountRemainingMinor: z
|
|
71
|
+
.number()
|
|
72
|
+
.int()
|
|
73
|
+
.nonnegative()
|
|
74
|
+
.nullable()
|
|
75
|
+
.describe("Server-computed remaining unpaid balance in minor units, or null when unknown (never coerced to 0) / Saldo pendiente en unidades menores o null si es desconocido"),
|
|
76
|
+
amountRemainingFormatted: z
|
|
77
|
+
.string()
|
|
78
|
+
.nullable()
|
|
79
|
+
.describe("Human-readable remaining balance with currency, or null when unknown / Saldo pendiente formateado"),
|
|
80
|
+
balanceProvenance: z
|
|
81
|
+
.enum(BALANCE_PROVENANCE_VALUES)
|
|
82
|
+
.describe("Server authority source for the remaining balance / Procedencia del saldo en el servidor"),
|
|
83
|
+
asOf: z.string().describe("ISO-8601 timestamp of the server evaluation / Marca temporal ISO-8601"),
|
|
84
|
+
authorizedPortalUrl: z
|
|
85
|
+
.string()
|
|
86
|
+
.nullable()
|
|
87
|
+
.describe("Signed Frihet invoice portal URL (/pay/{workspace}/{invoice}/{token}) when collectible=true; null otherwise / URL firmada del portal de pago"),
|
|
88
|
+
tokenLifetimePolicy: z
|
|
89
|
+
.literal("hmac_v1_stateless_revalidated_on_open")
|
|
90
|
+
.describe("Real token lifetime semantics: HMAC invoice:v1 link revalidated against live server collectibility and balance on open and payment / Semántica real de vigencia del enlace"),
|
|
91
|
+
sideEffects: z
|
|
92
|
+
.object({
|
|
93
|
+
emailSent: z.literal(false),
|
|
94
|
+
paymentInitiated: z.literal(false),
|
|
95
|
+
ledgerMutated: z.literal(false),
|
|
96
|
+
checkoutSessionCreated: z.literal(false),
|
|
97
|
+
})
|
|
98
|
+
.strict()
|
|
99
|
+
.describe("Explicit guarantee that retrieving a payment link performs no email, charge, or accounting mutation / Garantía explícita de cero efectos colaterales"),
|
|
100
|
+
};
|
|
101
|
+
export const InvoicePaymentLinkOutputSchema = z.object(invoicePaymentLinkOutputShape).strict();
|
|
102
|
+
export function assertSafePathSegment(value, label) {
|
|
103
|
+
if (typeof value !== "string" || !SAFE_PATH_SEGMENT_RE.test(value)) {
|
|
104
|
+
throw new Error(`Invalid ${label}: must be a safe non-empty path identifier without traversal segments`);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Validates that a candidate URL is strictly a canonical Frihet invoice portal
|
|
110
|
+
* URL (`https://app.frihet.io/pay/{workspaceId}/{invoiceId}/{token}`) bound to
|
|
111
|
+
* the exact `expectedWorkspaceId` and `expectedInvoiceId`, with no query
|
|
112
|
+
* parameters, fragments, credentials, or alternate origins.
|
|
113
|
+
*/
|
|
114
|
+
export function validatePortalPayUrl(rawUrl, expectedWorkspaceId, expectedInvoiceId) {
|
|
115
|
+
if (typeof rawUrl !== "string" || !rawUrl.trim()) {
|
|
116
|
+
return { valid: false, reason: "empty_or_non_string_url" };
|
|
117
|
+
}
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = new URL(rawUrl);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return { valid: false, reason: "malformed_url" };
|
|
124
|
+
}
|
|
125
|
+
if (parsed.protocol !== "https:") {
|
|
126
|
+
return { valid: false, reason: "non_https_protocol" };
|
|
127
|
+
}
|
|
128
|
+
if (parsed.origin !== CANONICAL_PORTAL_ORIGIN) {
|
|
129
|
+
return { valid: false, reason: "unapproved_origin" };
|
|
130
|
+
}
|
|
131
|
+
if (parsed.username || parsed.password) {
|
|
132
|
+
return { valid: false, reason: "embedded_credentials_forbidden" };
|
|
133
|
+
}
|
|
134
|
+
if (parsed.search || parsed.hash) {
|
|
135
|
+
return { valid: false, reason: "query_or_fragment_forbidden" };
|
|
136
|
+
}
|
|
137
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
138
|
+
if (segments.length !== 4 || segments[0] !== "pay") {
|
|
139
|
+
return { valid: false, reason: "invalid_portal_path_shape" };
|
|
140
|
+
}
|
|
141
|
+
const [, workspaceSegment, invoiceSegment, tokenSegment] = segments;
|
|
142
|
+
if (!SAFE_PATH_SEGMENT_RE.test(workspaceSegment) ||
|
|
143
|
+
!SAFE_PATH_SEGMENT_RE.test(invoiceSegment) ||
|
|
144
|
+
!SAFE_HMAC_TOKEN_RE.test(tokenSegment)) {
|
|
145
|
+
return { valid: false, reason: "invalid_portal_segment_encoding" };
|
|
146
|
+
}
|
|
147
|
+
if (workspaceSegment !== expectedWorkspaceId) {
|
|
148
|
+
return { valid: false, reason: "workspace_segment_mismatch" };
|
|
149
|
+
}
|
|
150
|
+
if (invoiceSegment !== expectedInvoiceId) {
|
|
151
|
+
return { valid: false, reason: "invoice_segment_mismatch" };
|
|
152
|
+
}
|
|
153
|
+
const canonicalUrl = `${CANONICAL_PORTAL_ORIGIN}/pay/${workspaceSegment}/${invoiceSegment}/${tokenSegment}`;
|
|
154
|
+
const maskedUrl = `${CANONICAL_PORTAL_ORIGIN}/pay/${workspaceSegment}/${invoiceSegment}/[redacted-token]`;
|
|
155
|
+
return { valid: true, url: canonicalUrl, maskedUrl };
|
|
156
|
+
}
|
|
157
|
+
function formatMinorAmount(amountMinor, currency) {
|
|
158
|
+
if (amountMinor === null || !Number.isSafeInteger(amountMinor) || amountMinor < 0) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const major = (amountMinor / 100).toFixed(2);
|
|
162
|
+
return `${major} ${currency}`;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Evaluates and normalizes the upstream server response into the fail-closed
|
|
166
|
+
* `InvoicePaymentLinkOutput` contract.
|
|
167
|
+
*/
|
|
168
|
+
export function projectInvoicePaymentLinkOutput(upstream, expectedWorkspaceId) {
|
|
169
|
+
const invoiceId = assertSafePathSegment(upstream.invoiceId, "invoiceId");
|
|
170
|
+
const workspaceId = assertSafePathSegment(upstream.workspaceId, "workspaceId");
|
|
171
|
+
const currency = typeof upstream.currency === "string" ? upstream.currency.trim().toUpperCase() : "";
|
|
172
|
+
if (!ISO_CURRENCY_RE.test(currency)) {
|
|
173
|
+
throw new Error("Upstream invoice payment link response returned an invalid ISO currency code");
|
|
174
|
+
}
|
|
175
|
+
const asOf = typeof upstream.asOf === "string" && !Number.isNaN(Date.parse(upstream.asOf))
|
|
176
|
+
? upstream.asOf
|
|
177
|
+
: new Date().toISOString();
|
|
178
|
+
// Tenant isolation check when caller supplies expectedWorkspaceId
|
|
179
|
+
if (expectedWorkspaceId && workspaceId !== expectedWorkspaceId) {
|
|
180
|
+
return {
|
|
181
|
+
invoiceId,
|
|
182
|
+
workspaceId: expectedWorkspaceId,
|
|
183
|
+
status: "unknown",
|
|
184
|
+
collectible: false,
|
|
185
|
+
unavailableReason: "tenant_mismatch",
|
|
186
|
+
currency,
|
|
187
|
+
amountRemainingMinor: null,
|
|
188
|
+
amountRemainingFormatted: null,
|
|
189
|
+
balanceProvenance: "unknown_degraded",
|
|
190
|
+
asOf,
|
|
191
|
+
authorizedPortalUrl: null,
|
|
192
|
+
tokenLifetimePolicy: "hmac_v1_stateless_revalidated_on_open",
|
|
193
|
+
sideEffects: {
|
|
194
|
+
emailSent: false,
|
|
195
|
+
paymentInitiated: false,
|
|
196
|
+
ledgerMutated: false,
|
|
197
|
+
checkoutSessionCreated: false,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// Never coerce unknown/degraded balances to zero
|
|
202
|
+
const rawMinor = upstream.amountRemainingMinor;
|
|
203
|
+
const hasKnownValidBalance = upstream.balanceProvenance !== "unknown_degraded" &&
|
|
204
|
+
typeof rawMinor === "number" &&
|
|
205
|
+
Number.isSafeInteger(rawMinor) &&
|
|
206
|
+
rawMinor >= 0;
|
|
207
|
+
const amountRemainingMinor = hasKnownValidBalance ? rawMinor : null;
|
|
208
|
+
// Determine collectibility fail-closed
|
|
209
|
+
let collectible = Boolean(upstream.collectible);
|
|
210
|
+
let unavailableReason = upstream.unavailableReason ?? null;
|
|
211
|
+
if (upstream.status === "draft") {
|
|
212
|
+
collectible = false;
|
|
213
|
+
unavailableReason = unavailableReason ?? "draft_invoice";
|
|
214
|
+
}
|
|
215
|
+
else if (upstream.status === "paid") {
|
|
216
|
+
collectible = false;
|
|
217
|
+
unavailableReason = unavailableReason ?? "already_paid";
|
|
218
|
+
}
|
|
219
|
+
else if (upstream.status === "cancelled") {
|
|
220
|
+
collectible = false;
|
|
221
|
+
unavailableReason = unavailableReason ?? "cancelled_invoice";
|
|
222
|
+
}
|
|
223
|
+
else if (upstream.status === "unknown" || !hasKnownValidBalance || amountRemainingMinor === null) {
|
|
224
|
+
collectible = false;
|
|
225
|
+
unavailableReason = unavailableReason ?? "unknown_balance";
|
|
226
|
+
}
|
|
227
|
+
else if (amountRemainingMinor <= 0) {
|
|
228
|
+
collectible = false;
|
|
229
|
+
unavailableReason = unavailableReason ?? "already_paid";
|
|
230
|
+
}
|
|
231
|
+
let authorizedPortalUrl = null;
|
|
232
|
+
if (collectible) {
|
|
233
|
+
const candidateUrl = upstream.portalUrl ?? upstream.paymentUrl;
|
|
234
|
+
const validation = validatePortalPayUrl(candidateUrl, workspaceId, invoiceId);
|
|
235
|
+
if (!validation.valid) {
|
|
236
|
+
collectible = false;
|
|
237
|
+
unavailableReason = "invalid_portal_url";
|
|
238
|
+
authorizedPortalUrl = null;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
authorizedPortalUrl = validation.url;
|
|
242
|
+
unavailableReason = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (!unavailableReason) {
|
|
246
|
+
unavailableReason = "not_collectible";
|
|
247
|
+
}
|
|
248
|
+
return InvoicePaymentLinkOutputSchema.parse({
|
|
249
|
+
invoiceId,
|
|
250
|
+
workspaceId,
|
|
251
|
+
status: upstream.status,
|
|
252
|
+
collectible,
|
|
253
|
+
unavailableReason,
|
|
254
|
+
currency,
|
|
255
|
+
amountRemainingMinor,
|
|
256
|
+
amountRemainingFormatted: formatMinorAmount(amountRemainingMinor, currency),
|
|
257
|
+
balanceProvenance: hasKnownValidBalance ? upstream.balanceProvenance : "unknown_degraded",
|
|
258
|
+
asOf,
|
|
259
|
+
authorizedPortalUrl,
|
|
260
|
+
tokenLifetimePolicy: "hmac_v1_stateless_revalidated_on_open",
|
|
261
|
+
sideEffects: {
|
|
262
|
+
emailSent: false,
|
|
263
|
+
paymentInitiated: false,
|
|
264
|
+
ledgerMutated: false,
|
|
265
|
+
checkoutSessionCreated: false,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Redacts a projected `InvoicePaymentLinkOutput` for logs/traces so capability
|
|
271
|
+
* URLs never leak into Langfuse or generic stderr logs.
|
|
272
|
+
*/
|
|
273
|
+
export function redactPaymentLinkForTrace(output) {
|
|
274
|
+
const cloned = redactClone(output, [...SENSITIVE_FIELD_NAMES]);
|
|
275
|
+
if (typeof cloned.authorizedPortalUrl === "string") {
|
|
276
|
+
const check = validatePortalPayUrl(cloned.authorizedPortalUrl, output.workspaceId, output.invoiceId);
|
|
277
|
+
cloned.authorizedPortalUrl = check.valid ? check.maskedUrl : "[redacted]";
|
|
278
|
+
}
|
|
279
|
+
return cloned;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Verifies that generic invoice payloads continue to have `paymentUrl` and
|
|
283
|
+
* `paymentLink` stripped in-place by `deepRedact`.
|
|
284
|
+
*/
|
|
285
|
+
export function enforceGenericInvoiceRedaction(record) {
|
|
286
|
+
deepRedact(record, SENSITIVE_FIELD_NAMES);
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* In-memory idempotency coordinator for `get_invoice_payment_link` retries.
|
|
291
|
+
* Ensures that retrying with the same `(idempotencyKey, invoiceId)` converges
|
|
292
|
+
* on the same response, while reusing the same `idempotencyKey` for a DIFFERENT
|
|
293
|
+
* `invoiceId` fails closed with `IDEMPOTENCY_KEY_CONFLICT`.
|
|
294
|
+
*/
|
|
295
|
+
export class PaymentLinkIdempotencyCoordinator {
|
|
296
|
+
records = new Map();
|
|
297
|
+
async execute(invoiceId, idempotencyKey, runner) {
|
|
298
|
+
const safeInvoiceId = assertSafePathSegment(invoiceId, "invoiceId");
|
|
299
|
+
if (!idempotencyKey) {
|
|
300
|
+
return runner();
|
|
301
|
+
}
|
|
302
|
+
const trimmedKey = idempotencyKey.trim();
|
|
303
|
+
if (!trimmedKey || trimmedKey.length > 128) {
|
|
304
|
+
throw new Error("Invalid idempotencyKey: must be 1..128 characters");
|
|
305
|
+
}
|
|
306
|
+
const existing = this.records.get(trimmedKey);
|
|
307
|
+
if (existing) {
|
|
308
|
+
if (existing.invoiceId !== safeInvoiceId) {
|
|
309
|
+
const conflict = new Error(`IDEMPOTENCY_KEY_CONFLICT: idempotencyKey "${trimmedKey}" was already used for a different invoice`);
|
|
310
|
+
conflict.code = "IDEMPOTENCY_KEY_CONFLICT";
|
|
311
|
+
conflict.statusCode = 409;
|
|
312
|
+
throw conflict;
|
|
313
|
+
}
|
|
314
|
+
return existing.promise;
|
|
315
|
+
}
|
|
316
|
+
const promise = runner().catch((err) => {
|
|
317
|
+
this.records.delete(trimmedKey);
|
|
318
|
+
throw err;
|
|
319
|
+
});
|
|
320
|
+
this.records.set(trimmedKey, { invoiceId: safeInvoiceId, promise });
|
|
321
|
+
return promise;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export function registerInvoicePaymentLinkTool(server, transport, options) {
|
|
325
|
+
const coordinator = new PaymentLinkIdempotencyCoordinator();
|
|
326
|
+
server.registerTool("get_invoice_payment_link", {
|
|
327
|
+
title: "Get Invoice Payment Link",
|
|
328
|
+
description: "Retrieve the signed Frihet payment portal link (/pay/{workspace}/{invoice}/{token}) and server-authoritative remaining balance for an issued collectible invoice. " +
|
|
329
|
+
"Does NOT send an email, issue a draft, initiate a charge, create a Checkout Session reservation, or mark the invoice paid. " +
|
|
330
|
+
"/ Obtiene el enlace firmado al portal de cobro y el saldo pendiente calculado por el servidor para una factura emitida cobrable. " +
|
|
331
|
+
"NO envía correos, no emite borradores, no inicia cargos ni marca la factura como pagada.",
|
|
332
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
333
|
+
inputSchema: {
|
|
334
|
+
id: z
|
|
335
|
+
.string()
|
|
336
|
+
.trim()
|
|
337
|
+
.min(1)
|
|
338
|
+
.max(128)
|
|
339
|
+
.describe("The invoice ID / ID de la factura"),
|
|
340
|
+
idempotencyKey: z
|
|
341
|
+
.string()
|
|
342
|
+
.trim()
|
|
343
|
+
.min(1)
|
|
344
|
+
.max(128)
|
|
345
|
+
.optional()
|
|
346
|
+
.describe("Optional idempotency key for safe retries / Clave de idempotencia opcional"),
|
|
347
|
+
},
|
|
348
|
+
outputSchema: invoicePaymentLinkOutputShape,
|
|
349
|
+
}, async ({ id, idempotencyKey }) => withToolLogging("get_invoice_payment_link", async () => {
|
|
350
|
+
const safeId = assertSafePathSegment(id, "id");
|
|
351
|
+
const output = await coordinator.execute(safeId, idempotencyKey, async () => {
|
|
352
|
+
const upstream = await transport.requestInvoicePaymentLink(safeId, {
|
|
353
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
354
|
+
});
|
|
355
|
+
return projectInvoicePaymentLinkOutput(upstream, options?.expectedWorkspaceId);
|
|
356
|
+
});
|
|
357
|
+
const summaryLines = [
|
|
358
|
+
`Invoice: ${output.invoiceId} (workspace: ${output.workspaceId})`,
|
|
359
|
+
`Status: ${output.status} | Collectible: ${output.collectible ? "yes" : "no"}${output.unavailableReason ? ` (${output.unavailableReason})` : ""}`,
|
|
360
|
+
`Remaining balance: ${output.amountRemainingFormatted ?? "unknown (degraded authority — not coerced to 0)"} [source: ${output.balanceProvenance}]`,
|
|
361
|
+
`Portal URL: ${output.authorizedPortalUrl ?? "unavailable"}`,
|
|
362
|
+
`Token policy: ${output.tokenLifetimePolicy}`,
|
|
363
|
+
`Side effects: emailSent=false, paymentInitiated=false, ledgerMutated=false, checkoutSessionCreated=false`,
|
|
364
|
+
];
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: summaryLines.join("\n") }],
|
|
367
|
+
structuredContent: output,
|
|
368
|
+
};
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
//# sourceMappingURL=invoice-payment-link.js.map
|