@teamscala/service-boot 0.23.408 → 0.23.409
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/package.json
CHANGED
package/src/fn-routers/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { publicGptSessionRouter } from "./public-gpt-session-router.ts";
|
|
|
30
30
|
import { signInFlowRouter } from "./sign-in-flow-router.ts";
|
|
31
31
|
import { userPreferencesRouter } from "./user-preferences-router.ts";
|
|
32
32
|
import { workItemsRouter } from "./work-items-router.ts";
|
|
33
|
+
import { messagingWhatsappRouter } from "./messaging-whatsapp-router.ts";
|
|
33
34
|
|
|
34
35
|
export function registerFnRouters(): void {
|
|
35
36
|
registerCustomRouter("fn", {
|
|
@@ -38,6 +39,7 @@ export function registerFnRouters(): void {
|
|
|
38
39
|
auth: authRouter,
|
|
39
40
|
cacheAdmin: cacheAdminRouter,
|
|
40
41
|
chatSession: chatSessionRouter,
|
|
42
|
+
messagingWhatsappSend: messagingWhatsappRouter.messagingWhatsappSend,
|
|
41
43
|
publicAgreement: publicAgreementRouter,
|
|
42
44
|
publicAssessment: publicAssessmentRouter,
|
|
43
45
|
publicGptSession: publicGptSessionRouter,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Proves fn.messagingWhatsappSend routes through scala-messaging-service: the
|
|
7
|
+
* URL is DERIVED from the service_install row (never spelled), the sealed-row
|
|
8
|
+
* credential is passed through the decrypt mock (identity, matching the
|
|
9
|
+
* public-assessment-router test pattern), the request carries the internal
|
|
10
|
+
* auth header, and the caller receives WhatsApp's message_id — the only thing
|
|
11
|
+
* that makes delivery provable. Failure modes (401, blank input) fail loudly
|
|
12
|
+
* with named causes, never a generic send error.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createRouterClient } from "@orpc/server";
|
|
16
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
const SEND_CALLS: Array<{ url: string; headers: Record<string, string>; body: unknown }> = [];
|
|
19
|
+
|
|
20
|
+
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
21
|
+
loadRegistryConfig: async (type: string, slug: string) => {
|
|
22
|
+
if (type === "service_install" && slug === "scala-messaging-service") {
|
|
23
|
+
return { deploy_target: "railway", port: 8080 };
|
|
24
|
+
}
|
|
25
|
+
if (type === "secret" && slug === "railway-scala-messaging-whatsapp-webhook-secret") {
|
|
26
|
+
return { token: "test-token" };
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`unexpected loadRegistryConfig(${type}, ${slug})`);
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Identity decrypt (the public-assessment-router test pattern).
|
|
33
|
+
mock.module("@teamscala/encryption/crypto/secret-config", () => ({
|
|
34
|
+
decryptSecretConfig: (raw: unknown) => raw,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
38
|
+
getAppLogger: () => ({
|
|
39
|
+
info: () => {},
|
|
40
|
+
warn: () => {},
|
|
41
|
+
error: () => {},
|
|
42
|
+
debug: () => {},
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const originalFetch = globalThis.fetch;
|
|
47
|
+
(globalThis as { fetch: unknown }).fetch = async (url: string, init: RequestInit) => {
|
|
48
|
+
SEND_CALLS.push({
|
|
49
|
+
url,
|
|
50
|
+
headers: init.headers as Record<string, string>,
|
|
51
|
+
body: JSON.parse(init.body as string),
|
|
52
|
+
});
|
|
53
|
+
const body = JSON.parse(init.body as string) as { phone?: string };
|
|
54
|
+
if (body.phone === "0-0-1-refuse") {
|
|
55
|
+
return new Response("no", { status: 401 });
|
|
56
|
+
}
|
|
57
|
+
return Response.json({ message_id: "3EB07EE8TESTID" });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const { messagingWhatsappRouter } = await import("./messaging-whatsapp-router.ts");
|
|
61
|
+
|
|
62
|
+
// Invoke through a real ORPC client so the authed middleware runs too —
|
|
63
|
+
// calling the DecoratedProcedure directly is not a round trip.
|
|
64
|
+
const client = createRouterClient(
|
|
65
|
+
{ messagingWhatsappSend: messagingWhatsappRouter.messagingWhatsappSend } as never,
|
|
66
|
+
{
|
|
67
|
+
context: {
|
|
68
|
+
user: { id: "system", organisationId: null },
|
|
69
|
+
headers: new Headers(),
|
|
70
|
+
prisma: {},
|
|
71
|
+
} as never,
|
|
72
|
+
},
|
|
73
|
+
) as { messagingWhatsappSend: (a: unknown) => Promise<unknown> };
|
|
74
|
+
|
|
75
|
+
describe("fn.messagingWhatsappSend", () => {
|
|
76
|
+
test("derives the URL from service_install, sends with the decrypted header value, returns the message_id", async () => {
|
|
77
|
+
SEND_CALLS.length = 0;
|
|
78
|
+
const result = await client.messagingWhatsappSend({
|
|
79
|
+
phone: "61428193498",
|
|
80
|
+
message: "migration probe",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(SEND_CALLS).toHaveLength(1);
|
|
84
|
+
expect(SEND_CALLS[0]?.url).toBe(
|
|
85
|
+
"http://scala-messaging-service.railway.internal:8080/api/messaging/whatsapp/send",
|
|
86
|
+
);
|
|
87
|
+
expect(SEND_CALLS[0]?.headers["x-webhook-secret"]).toBe("test-token");
|
|
88
|
+
expect(SEND_CALLS[0]?.body).toEqual({ phone: "61428193498", message: "migration probe" });
|
|
89
|
+
expect(result).toEqual({ ok: true, message_id: "3EB07EE8TESTID" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a 401 from the messaging service names the credential, not a generic failure", async () => {
|
|
93
|
+
SEND_CALLS.length = 0;
|
|
94
|
+
const result = await client.messagingWhatsappSend({
|
|
95
|
+
phone: "0-0-1-refuse",
|
|
96
|
+
message: "x",
|
|
97
|
+
});
|
|
98
|
+
expect(result).toEqual({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: "messaging service refused the webhook secret (401)",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("blank phone or message is refused before any request is made", async () => {
|
|
105
|
+
SEND_CALLS.length = 0;
|
|
106
|
+
const blank = await client.messagingWhatsappSend({
|
|
107
|
+
phone: " ",
|
|
108
|
+
message: "x",
|
|
109
|
+
});
|
|
110
|
+
expect(blank).toEqual({ ok: false, error: "phone and message are required" });
|
|
111
|
+
expect(SEND_CALLS).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Restore INSIDE afterAll — a top-level restore would run before the tests
|
|
116
|
+
// (all top-level code executes first) and hand the router back the real fetch.
|
|
117
|
+
afterAll(() => {
|
|
118
|
+
(globalThis as { fetch: unknown }).fetch = originalFetch;
|
|
119
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* fn.messagingWhatsappSend — the agent-facing WhatsApp send that routes through
|
|
7
|
+
* scala-messaging-service (the Rust owner of the channel), replacing the
|
|
8
|
+
* retired TS @teamscala/api-gowa SDK send path (operator ruling 2026-08-26:
|
|
9
|
+
* "make sure the migration is finished, i cant have half ts and half rust").
|
|
10
|
+
*
|
|
11
|
+
* The messaging service's POST /api/messaging/whatsapp/send is the single send
|
|
12
|
+
* surface: it resolves the sending device, returns WhatsApp's message_id for
|
|
13
|
+
* delivery correlation, and records the send in messaging_logs itself (Rust,
|
|
14
|
+
* `write_outbound_tracked`) — better than the deleted TS recorder, which only
|
|
15
|
+
* ran when the send happened to flow through tool-executor.
|
|
16
|
+
*
|
|
17
|
+
* Both facts this procedure needs are DERIVED at call time, never spelled:
|
|
18
|
+
* - the service URL comes from the service_install row (slug + port +
|
|
19
|
+
* deploy_target → Railway private DNS <slug>.railway.internal:<port>), the
|
|
20
|
+
* same derivation every *.railway.internal consumer uses;
|
|
21
|
+
* - the auth credential is the existing sealed secret row
|
|
22
|
+
* railway-scala-messaging-whatsapp-webhook-secret, decrypted per
|
|
23
|
+
* registry-via-helper + the agreement-send precedent.
|
|
24
|
+
*
|
|
25
|
+
* Registered flat under the `fn` namespace (two-segment procedure
|
|
26
|
+
* "fn.messagingWhatsappSend") because the tool-executor ORPC dispatch resolves
|
|
27
|
+
* "model.method" — nested routers would not be reachable as a tool procedure.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { os } from "@orpc/server";
|
|
31
|
+
import * as v from "valibot";
|
|
32
|
+
import {
|
|
33
|
+
decryptSecretConfig,
|
|
34
|
+
type SecretConfigLike,
|
|
35
|
+
} from "@teamscala/encryption/crypto/secret-config";
|
|
36
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
37
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
38
|
+
|
|
39
|
+
interface User {
|
|
40
|
+
id: string;
|
|
41
|
+
organisationId: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Context {
|
|
45
|
+
user: User | null | undefined;
|
|
46
|
+
headers: Headers;
|
|
47
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
|
|
48
|
+
prisma: any;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const authed = os.$context<Context>().use(async (opt) => {
|
|
52
|
+
const { context } = opt;
|
|
53
|
+
let user = context.user;
|
|
54
|
+
if (!user && context.headers && context.resolveUser) {
|
|
55
|
+
user = await context.resolveUser(context.headers, context.prisma);
|
|
56
|
+
}
|
|
57
|
+
if (!user) {
|
|
58
|
+
const { ORPCError } = await import("@orpc/server");
|
|
59
|
+
throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
|
|
60
|
+
}
|
|
61
|
+
return await opt.next({ context: { ...context, user } });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** service_install shape for deriving the messaging service's internal URL. */
|
|
65
|
+
const MessagingInstallSchema = v.looseObject({
|
|
66
|
+
port: v.optional(v.number(), 8080),
|
|
67
|
+
deploy_target: v.string(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** The sealed webhook-secret row shape (value is decryptSecretConfig input). */
|
|
71
|
+
const MessagingSecretSchema = v.looseObject({});
|
|
72
|
+
|
|
73
|
+
/** Derive the messaging service base URL from its service_install row. */
|
|
74
|
+
async function resolveMessagingBaseUrl(): Promise<string> {
|
|
75
|
+
const install = await loadRegistryConfig(
|
|
76
|
+
"service_install",
|
|
77
|
+
"scala-messaging-service",
|
|
78
|
+
MessagingInstallSchema,
|
|
79
|
+
);
|
|
80
|
+
if (install.deploy_target !== "railway") {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`scala-messaging-service deploy_target is "${install.deploy_target}", not "railway" — ` +
|
|
83
|
+
"the private-DNS URL derivation only holds on Railway. Fix the service_install row.",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return `http://scala-messaging-service.railway.internal:${install.port}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sendSchema = v.object({
|
|
90
|
+
/** Destination phone number in E.164 (e.g. 61428193498). */
|
|
91
|
+
phone: v.string(),
|
|
92
|
+
/** Text body to send. */
|
|
93
|
+
message: v.string(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const messagingWhatsappRouter = {
|
|
97
|
+
messagingWhatsappSend: authed
|
|
98
|
+
.input(sendSchema)
|
|
99
|
+
.handler(async ({ input }) => {
|
|
100
|
+
const log = getAppLogger();
|
|
101
|
+
const phone = input.phone.trim();
|
|
102
|
+
const message = input.message;
|
|
103
|
+
if (!phone || !message) {
|
|
104
|
+
return { ok: false as const, error: "phone and message are required" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const [baseUrl, rawSecret] = await Promise.all([
|
|
109
|
+
resolveMessagingBaseUrl(),
|
|
110
|
+
loadRegistryConfig(
|
|
111
|
+
"secret",
|
|
112
|
+
"railway-scala-messaging-whatsapp-webhook-secret",
|
|
113
|
+
MessagingSecretSchema,
|
|
114
|
+
).catch(() => {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"secret row railway-scala-messaging-whatsapp-webhook-secret is missing — " +
|
|
117
|
+
"the messaging service send endpoint cannot be authenticated",
|
|
118
|
+
);
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
const decrypted = decryptSecretConfig(
|
|
122
|
+
rawSecret as SecretConfigLike,
|
|
123
|
+
) as { token?: string };
|
|
124
|
+
if (!decrypted.token) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false as const,
|
|
127
|
+
error: "whatsapp webhook secret row has no token",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const response = await fetch(
|
|
132
|
+
`${baseUrl}/api/messaging/whatsapp/send`,
|
|
133
|
+
{
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: {
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
// The Rust handler accepts x-webhook-secret (or
|
|
138
|
+
// x-api-key) against WHATSAPP_WEBHOOK_SECRET.
|
|
139
|
+
"x-webhook-secret": decrypted.token,
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({ phone, message }),
|
|
142
|
+
signal: AbortSignal.timeout(30_000),
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
if (response.status === 401) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false as const,
|
|
148
|
+
error: "messaging service refused the webhook secret (401)",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const body = (await response.json().catch(() => null)) as {
|
|
152
|
+
message_id?: string;
|
|
153
|
+
error?: string;
|
|
154
|
+
} | null;
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false as const,
|
|
158
|
+
error:
|
|
159
|
+
body?.error ??
|
|
160
|
+
`messaging service send failed: ${response.status}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// The message_id is the only thing that makes delivery provable —
|
|
164
|
+
// hand it back so the caller can correlate the ack (gowa.rs
|
|
165
|
+
// parse_message_id contract).
|
|
166
|
+
log.info("[fn.messagingWhatsappSend] sent", {
|
|
167
|
+
phone,
|
|
168
|
+
message_id: body?.message_id ?? null,
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
ok: true as const,
|
|
172
|
+
message_id: body?.message_id ?? null,
|
|
173
|
+
};
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
176
|
+
log.error(`[fn.messagingWhatsappSend] failed: ${errorMessage}`);
|
|
177
|
+
return { ok: false as const, error: errorMessage };
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
};
|
package/src/mount-orpc-router.ts
CHANGED
|
@@ -87,6 +87,14 @@ export async function mountOrpcRouter(
|
|
|
87
87
|
configureToolExecutor({
|
|
88
88
|
getAppRouter: () => getAppRouter(),
|
|
89
89
|
getPrisma: () => getConfiguredPrisma(),
|
|
90
|
+
// Central MCP gateway URL — sdk:* tool calls from any non-host service
|
|
91
|
+
// forward here (scala-mcp owns every @teamscala/api-* adapter per
|
|
92
|
+
// operator ruling 2026-08-26). The HTTP layer reads from the
|
|
93
|
+
// per-request config surface (the same place getAppRouter / getPrisma
|
|
94
|
+
// are resolved from), not the ambient environment, to honour
|
|
95
|
+
// `configured-primitives`.
|
|
96
|
+
getMcpGatewayUrl: () =>
|
|
97
|
+
(ctx as { config?: { MCP_GATEWAY_URL?: string } }).config?.MCP_GATEWAY_URL,
|
|
90
98
|
});
|
|
91
99
|
|
|
92
100
|
const resolveUser = async (headers: Headers) => {
|