Skip to content

Commit 2dfa865

Browse files
committed
fix(coexist): keep a chunked history sync draining across continuations
Also threads the workspace id through the WhatsApp phone-number listing, keeps provider URLs in persisted error messages, and matches public routes by segment.
1 parent c196ee1 commit 2dfa865

17 files changed

Lines changed: 401 additions & 51 deletions

File tree

apps/builder/__tests__/proxy-public-routes.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@ describe("isPublicRoute", () => {
2121
expect(isPublicRoute("/channels/create")).toBe(false)
2222
})
2323

24-
test("the /t/ prefix keeps its trailing slash so /templates stays private", () => {
24+
test("matching is by segment, so no entry opens a longer first segment", () => {
25+
// "/t" vs "/templates" is the case that already bit us; the same bare
26+
// `startsWith` would have opened "/rpcadmin" or "/storage-exports" the
27+
// day either route appeared.
2528
expect(isPublicRoute("/t/abc")).toBe(true)
2629
expect(isPublicRoute("/templates")).toBe(false)
30+
expect(isPublicRoute("/rpcadmin")).toBe(false)
31+
expect(isPublicRoute("/apikeys")).toBe(false)
32+
expect(isPublicRoute("/authorized-apps")).toBe(false)
2733
})
2834
})
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { readdirSync, readFileSync } from "node:fs"
2+
import { join, relative } from "node:path"
3+
import { describe, expect, test } from "vitest"
4+
5+
/**
6+
* `/rpc` is a public route in the proxy (see `lib/public-routes.ts`), so the
7+
* only thing standing between an anonymous request and a feature handler is
8+
* the auth middleware each procedure carries. Nothing type-checks that: a
9+
* procedure built on a raw oRPC base compiles, mounts, and answers without a
10+
* session.
11+
*
12+
* These tests pin the structural reason that cannot happen — `@/orpc` exports
13+
* no unauthenticated base, and no feature api module imports one from
14+
* anywhere else.
15+
*/
16+
17+
const BUILDER_SRC = join(import.meta.dirname, "..", "src")
18+
19+
/** Modules that may hold a raw base, with the reason they are not procedures. */
20+
const RAW_BASE_ALLOWLIST = new Set([
21+
// A middleware, not a procedure: it is chained onto the connect routes,
22+
// which are themselves built on `authorizedAPI`.
23+
"features/channel-connect/api/audit-context.ts",
24+
])
25+
26+
const AUTHENTICATED_BASE =
27+
/\b(authorizedAPI|workspaceTokenAuthAPIForScope|channelApiTokenAPI)\b/
28+
29+
const PROCEDURE_HANDLER = /\.handler\(/
30+
31+
const EXPORTED_CONST = /^export const (\w+)/gm
32+
33+
const RAW_BASE_IMPORT =
34+
/import\s*\{[^}]*\b(?:base|os|implement)\b[^}]*\}\s*from\s*["'](?:@orpc\/server|@\/middlewares\/context)["']/
35+
36+
/** Every `.ts` under a `features/<name>/api/` directory, recursively. */
37+
const apiModules = (): string[] => {
38+
const featuresDir = join(BUILDER_SRC, "features")
39+
const collect = (dir: string): string[] =>
40+
readdirSync(dir, { withFileTypes: true, recursive: true })
41+
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
42+
.map((entry) => join(entry.parentPath, entry.name))
43+
44+
return readdirSync(featuresDir, { withFileTypes: true })
45+
.filter((entry) => entry.isDirectory())
46+
.flatMap((feature) => {
47+
const apiDir = join(featuresDir, feature.name, "api")
48+
try {
49+
return collect(apiDir)
50+
} catch {
51+
return []
52+
}
53+
})
54+
}
55+
56+
describe("/rpc router auth surface", () => {
57+
test("@/orpc exports only authenticated procedure bases", () => {
58+
const source = readFileSync(join(BUILDER_SRC, "orpc.ts"), "utf8")
59+
const exported = [...source.matchAll(EXPORTED_CONST)].map(
60+
(match) => match[1],
61+
)
62+
63+
// Adding an export here means adding a way to mount a procedure. If it is
64+
// not one of these three, it must carry its own auth middleware — and this
65+
// test is where that decision gets recorded.
66+
expect(exported.sort()).toEqual([
67+
"authorizedAPI",
68+
"channelApiTokenAPI",
69+
"workspaceTokenAuthAPIForScope",
70+
])
71+
})
72+
73+
test("no feature api module imports a raw oRPC base", () => {
74+
const offenders: string[] = []
75+
76+
for (const file of apiModules()) {
77+
const relativePath = relative(BUILDER_SRC, file)
78+
if (RAW_BASE_ALLOWLIST.has(relativePath)) {
79+
continue
80+
}
81+
if (RAW_BASE_IMPORT.test(readFileSync(file, "utf8"))) {
82+
offenders.push(relativePath)
83+
}
84+
}
85+
86+
expect(offenders).toEqual([])
87+
})
88+
89+
test("every feature api module builds on one of the authenticated bases", () => {
90+
const files = apiModules()
91+
expect(files.length).toBeGreaterThan(50)
92+
93+
// Helper modules that define no procedure at all are not a surface.
94+
const procedureModules = files
95+
.map((file) => ({
96+
path: relative(BUILDER_SRC, file),
97+
source: readFileSync(file, "utf8"),
98+
}))
99+
.filter(({ source }) => PROCEDURE_HANDLER.test(source))
100+
// Guards the filter itself: an empty set would make the check vacuous.
101+
expect(procedureModules.length).toBeGreaterThan(50)
102+
103+
const unauthenticated = procedureModules
104+
.filter(({ source }) => !AUTHENTICATED_BASE.test(source))
105+
.map(({ path }) => path)
106+
107+
expect(unauthenticated).toEqual([])
108+
})
109+
})

apps/builder/__tests__/whatsapp-connect-card.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,13 @@ describe("WhatsappCreate connect card", () => {
182182
clickButtonByText("actions.continue")
183183
await flush()
184184

185+
// `workspaceId` is load-bearing: the procedure hands it to
186+
// `resolvePlatformOwnerId`, so dropping it resolves the platform-global
187+
// WhatsApp credential instead of the reseller's for a sub-account.
185188
expect(listWhatsappPhoneNumbersInternalAPI).toHaveBeenCalledWith({
186189
wabaId: "waba-1",
187190
accessToken: "token-1",
191+
workspaceId: "ws-1",
188192
})
189193
expect(radios()).toHaveLength(1)
190194

apps/builder/src/features/integration-whatsapp/components/whatsapp-connect-sections.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,15 @@ export const SWITCH_FIELD_CLASS =
3131
* The manual path's phone-number lookup: the numbers Meta returned for the
3232
* WABA id + token typed into the form, cleared again whenever the operator
3333
* leaves manual mode so a stale list can never be submitted.
34+
*
35+
* `workspaceId` is what lets `resolvePlatformOwnerId` reach the reseller's
36+
* WhatsApp credential; without it the listing falls back to the acting user
37+
* and a sub-account resolves the platform-global credential instead.
3438
*/
35-
function useManualPhoneNumbers(isManualConnect: boolean) {
39+
function useManualPhoneNumbers(
40+
isManualConnect: boolean,
41+
workspaceId?: string | null,
42+
) {
3643
const t = useTranslations()
3744
const { getValues, setValue } = useFormContext()
3845
const [phoneNumbers, setPhoneNumbers] = useState<WhatsappPhoneNumber[]>([])
@@ -65,6 +72,7 @@ function useManualPhoneNumbers(isManualConnect: boolean) {
6572
{
6673
wabaId: formData.wabaId ?? "",
6774
accessToken: formData.accessToken ?? "",
75+
workspaceId: workspaceId ?? undefined,
6876
},
6977
)
7078

@@ -77,7 +85,7 @@ function useManualPhoneNumbers(isManualConnect: boolean) {
7785
await clientErrorHandler(error)
7886
}
7987
})
80-
}, [getValues, t])
88+
}, [getValues, t, workspaceId])
8189

8290
return { phoneNumbers, isLoading, listPhoneNumbers }
8391
}
@@ -206,6 +214,7 @@ export function PhoneNumberSelectionSection({
206214

207215
type ManualConnectSectionProps = {
208216
watchManualConnect: boolean
217+
workspaceId?: string | null
209218
}
210219

211220
/** WABA id + access token, until they yield a phone-number list. */
@@ -280,10 +289,13 @@ function ManualPhoneNumberStep({
280289

281290
export function ManualConnectSection({
282291
watchManualConnect,
292+
workspaceId,
283293
}: ManualConnectSectionProps) {
284294
const t = useTranslations()
285-
const { phoneNumbers, isLoading, listPhoneNumbers } =
286-
useManualPhoneNumbers(watchManualConnect)
295+
const { phoneNumbers, isLoading, listPhoneNumbers } = useManualPhoneNumbers(
296+
watchManualConnect,
297+
workspaceId,
298+
)
287299

288300
return (
289301
<>

apps/builder/src/features/integration-whatsapp/components/whatsapp-create.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@ export default function WhatsappCreate({
116116
}
117117

118118
if (watchManualConnect) {
119-
return <ManualConnectSection watchManualConnect={watchManualConnect} />
119+
return (
120+
<ManualConnectSection
121+
watchManualConnect={watchManualConnect}
122+
workspaceId={workspaceId}
123+
/>
124+
)
120125
}
121126

122127
return (

apps/builder/src/lib/public-routes.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,22 @@ export const PUBLIC_ROUTES = [
2626
"/booking",
2727
"/portal/redeem",
2828
"/webchat",
29-
// Trailing slash is deliberate: `isPublicRoute` below is a bare
30-
// unanchored `startsWith`, so "/t" (no slash) would also match
31-
// "/templates" and make the authenticated template list world-readable.
3229
"/t/",
3330
]
3431

3532
/**
36-
* Whether the middleware lets a request through without a session. A prefix
37-
* added to `PUBLIC_ROUTES` silently opens every path under it, so the list is
38-
* pinned by a test.
33+
* Whether the middleware lets a request through without a session.
34+
*
35+
* Matching is by path SEGMENT, never by bare `startsWith`: a plain prefix test
36+
* opens far more than the entry names — "/t" would also match "/templates",
37+
* and "/rpc" would match a future "/rpcadmin". A trailing slash on an entry is
38+
* therefore cosmetic here, and an entry still opens everything nested under it,
39+
* so the list is pinned by a test.
3940
*/
4041
export function isPublicRoute(pathname: string) {
4142
for (const route of PUBLIC_ROUTES) {
42-
if (pathname.startsWith(route)) {
43+
const base = route.endsWith("/") ? route.slice(0, -1) : route
44+
if (pathname === base || pathname.startsWith(`${base}/`)) {
4345
return true
4446
}
4547
}

apps/worker/__tests__/coexist-whatsapp-flush-lifecycle.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,4 +781,68 @@ describe("coexistWhatsappFlush — run lifecycle", () => {
781781
]
782782
expect(bulkArgs?.batch).toEqual([])
783783
})
784+
785+
// ── the chunk chain must hand the run back before queueing the next one ──
786+
// `claimRun` refuses a `running` run whose heartbeat is under 10 minutes
787+
// old. A continuation queued while this worker still held the claim
788+
// therefore lost its own claim and abandoned, leaving the chain to the
789+
// scheduler's 1-hour stale sweep — one chunk per hour, then `failed`.
790+
791+
/** Makes the post-drain tail re-check find a late row, forcing a continuation. */
792+
const stageLateTailRow = () => {
793+
harness.lastStagedChain?.limit
794+
.mockReset()
795+
.mockResolvedValueOnce([])
796+
.mockResolvedValueOnce([{ id: "row-late" }])
797+
.mockResolvedValue([])
798+
}
799+
800+
it("releases the claim before queueing the continuation, so the next chunk can claim it", async () => {
801+
wireSelect(runRow(), [])
802+
stageLateTailRow()
803+
804+
await coexistWhatsappFlush({ runId, phoneNumberId })
805+
806+
const released = setPayloads().find(
807+
(payload) => payload.status === "init" && "lastHeartbeatAt" in payload,
808+
)
809+
expect(released?.status).toBe("init")
810+
expect(released?.lastHeartbeatAt).toBeInstanceOf(Date)
811+
// The run must be `init` BEFORE the job exists, or the continuation can
812+
// start against a still-`running` row and abandon.
813+
expect(Math.min(...mockRunWrite.mock.invocationCallOrder)).toBeLessThan(
814+
mockQueueAdd.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
815+
)
816+
expect(mockQueueAdd).toHaveBeenCalledWith(
817+
"coexistWhatsappFlush",
818+
expect.objectContaining({
819+
data: { runId, phoneNumberId },
820+
}),
821+
expect.anything(),
822+
)
823+
// Still a guarded write: a worker that lost the run cannot release it.
824+
expect(mockRunWriteGuards()).toContainEqual(
825+
expect.objectContaining({ status: "running" }),
826+
)
827+
})
828+
829+
it("queues no continuation when the release finds the run already reclaimed", async () => {
830+
// Write 0 is the drain's ownership heartbeat (still ours); write 1 is the
831+
// release, which a reclaim by another worker makes match no rows.
832+
wireUpdate((callIndex) => (callIndex === 0 ? 1 : 0))
833+
wireSelect(runRow(), [])
834+
stageLateTailRow()
835+
836+
await coexistWhatsappFlush({ runId, phoneNumberId })
837+
838+
expect(mockQueueAdd).not.toHaveBeenCalled()
839+
expect(mockWarn).toHaveBeenCalledWith(
840+
expect.objectContaining({
841+
runId,
842+
phoneNumberId,
843+
reason: "release before continuation matched no rows",
844+
}),
845+
expect.stringContaining("no longer claimed"),
846+
)
847+
})
784848
})

apps/worker/src/integration/handlers/coexist/whatsapp-flush.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -402,13 +402,45 @@ const resolveFinalStatus = async (
402402
}
403403

404404
/**
405-
* Hot-chains the next chunk. On enqueue failure the run is handed back to the
406-
* scheduler rather than left `running` with nobody driving it.
405+
* Hands the run back before the continuation is queued.
406+
*
407+
* `claimRun` refuses a run that is `running` with a heartbeat under 10 minutes
408+
* old — that is what stops two workers driving one run. A continuation
409+
* enqueued while this worker still holds the claim therefore loses its own
410+
* claim and abandons, so the chunk chain has to release ownership first: back
411+
* to `init` with a fresh heartbeat, counters and pending patches untouched,
412+
* exactly as `resetForRetry` hands a run back after a transient error. The
413+
* refreshed `updatedAt` also keeps `pickDueRuns` (which only considers `init`
414+
* runs idle for 10s) from racing a second job in alongside the continuation.
415+
*
416+
* @returns false when the claim was already lost, in which case no
417+
* continuation is queued — whoever holds the run now is driving it.
418+
*/
419+
const releaseForContinuation = async (
420+
context: FlushContext,
421+
): Promise<boolean> => {
422+
const written = await coexistService.updateProgress({
423+
runId: context.runId,
424+
expect: context.guard,
425+
fields: { status: "init", lastHeartbeatAt: new Date() },
426+
})
427+
428+
return written > 0
429+
}
430+
431+
/**
432+
* Hot-chains the next chunk. On enqueue failure the run is left `init` for the
433+
* scheduler rather than `running` with nobody driving it.
407434
*/
408435
const enqueueContinuation = async (
409436
context: FlushContext,
410437
state: FlushState,
411438
): Promise<void> => {
439+
if (!(await releaseForContinuation(context))) {
440+
abandon(context, "release before continuation matched no rows")
441+
return
442+
}
443+
412444
try {
413445
await integrationQueue.add(
414446
IntegrationJobAction.coexistWhatsappFlush,
@@ -436,15 +468,13 @@ const enqueueContinuation = async (
436468
"[coexist] WhatsApp flush chunk done — continuation enqueued",
437469
)
438470
} catch (error) {
471+
// The run is already `init` with a fresh heartbeat, so the scheduler's
472+
// next pass drives it — nothing left to write here, and the claim this
473+
// worker held is gone.
439474
logger.error(
440475
{ error, runId: context.runId },
441476
"[coexist] WhatsApp continuation enqueue failed — fallback to scheduler",
442477
)
443-
await coexistService.resetForRetry({
444-
runId: context.runId,
445-
currentError: state.currentError ?? "continuation enqueue failed",
446-
expect: context.guard,
447-
})
448478
}
449479
}
450480

0 commit comments

Comments
 (0)