okengine 0.7.0 → 0.9.0
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 +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +44 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +20 -27
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +172 -0
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -111
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -149
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real app-boot path for fx.store(db).upsert — must work through
|
|
3
|
+
* oke() → createTestApp → app.fetch(), not only the isolated SqlStoreHandle.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { gate } from "../gate.ts";
|
|
9
|
+
import { oke } from "../../kernel/app.ts";
|
|
10
|
+
import { createFx } from "../../kernel/fx.ts";
|
|
11
|
+
import { flow, resetFlowSeq } from "../../kernel/flow.ts";
|
|
12
|
+
import { on, resetBindings } from "../../kernel/on.ts";
|
|
13
|
+
import { http } from "../../kernel/triggers.ts";
|
|
14
|
+
import { createTestApp } from "../../test/create-test-app.ts";
|
|
15
|
+
import { field, id, now, store } from "../store.ts";
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetBindings();
|
|
19
|
+
resetFlowSeq();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const notes = store.schema.table("notes", {
|
|
23
|
+
id: field.text().primaryKey().defaultFn(id),
|
|
24
|
+
title: field.text().notNull(),
|
|
25
|
+
body: field.text().notNull(),
|
|
26
|
+
createdAt: field.integer().notNull().defaultFn(now),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const UpsertIn = z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
title: z.string(),
|
|
32
|
+
body: z.string(),
|
|
33
|
+
onExisting: z.enum(["update"]).optional(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const UpsertOut = z.object({
|
|
37
|
+
status: z.enum(["upserted", "changed", "already-existed"]),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("fx.store(db).upsert via app.fetch (real boot)", () => {
|
|
41
|
+
test("abstract schema table: upserted → already-existed → changed", async () => {
|
|
42
|
+
resetBindings();
|
|
43
|
+
resetFlowSeq();
|
|
44
|
+
|
|
45
|
+
const db = store.sql("app", { schema: { notes } });
|
|
46
|
+
|
|
47
|
+
const seed = on(
|
|
48
|
+
http.post("/seed").gate(gate.public),
|
|
49
|
+
flow({
|
|
50
|
+
name: "notes.seed",
|
|
51
|
+
in: UpsertIn,
|
|
52
|
+
out: UpsertOut,
|
|
53
|
+
effects: { writes: ["sql:app"], reads: ["sql:app"] },
|
|
54
|
+
do: async (input, fx) =>
|
|
55
|
+
fx
|
|
56
|
+
.store(db)
|
|
57
|
+
.upsert(
|
|
58
|
+
notes,
|
|
59
|
+
{ id: input.id },
|
|
60
|
+
{ id: input.id, title: input.title, body: input.body, createdAt: 1 },
|
|
61
|
+
input.onExisting ? { onExisting: input.onExisting } : undefined,
|
|
62
|
+
),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const app = oke({
|
|
67
|
+
name: "upsert-app-boot",
|
|
68
|
+
gate: { policies: [gate.public] },
|
|
69
|
+
}).adopt({ seed });
|
|
70
|
+
Object.assign(app.$options, { env: "test", stores: [db], unguardedHttp: "allow" });
|
|
71
|
+
await createTestApp(app);
|
|
72
|
+
|
|
73
|
+
async function post(body: unknown) {
|
|
74
|
+
const res = await app.fetch(
|
|
75
|
+
new Request("http://localhost/seed", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/json" },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
const json = (await res.json()) as { data: { status: string } };
|
|
83
|
+
return json.data.status;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
expect(await post({ id: "welcome", title: "Hello", body: "one" })).toBe("upserted");
|
|
87
|
+
expect(await post({ id: "welcome", title: "Changed", body: "two" })).toBe("already-existed");
|
|
88
|
+
expect(
|
|
89
|
+
await post({
|
|
90
|
+
id: "welcome",
|
|
91
|
+
title: "Updated",
|
|
92
|
+
body: "three",
|
|
93
|
+
onExisting: "update",
|
|
94
|
+
}),
|
|
95
|
+
).toBe("changed");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("SQL StoreDecl without store runtime fails loudly (not stub upsert-missing)", async () => {
|
|
99
|
+
const db = store.sql("app", { schema: { notes } });
|
|
100
|
+
const fx = createFx({ flow: "orphan" });
|
|
101
|
+
expect(() => fx.store(db)).toThrow(/no store runtime/);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/elements/store.ts
CHANGED
|
@@ -91,6 +91,9 @@ export type {
|
|
|
91
91
|
ResolvedListConfig,
|
|
92
92
|
} from "./store/resource.ts";
|
|
93
93
|
|
|
94
|
+
export { defineSeed, normalizeSeedFns, resolveSeedCategory } from "./store/seed.ts";
|
|
95
|
+
export type { SeedCategory, SeedDef, SeedFn, SeedFns } from "./store/seed.ts";
|
|
96
|
+
|
|
94
97
|
export { createSqlStoreHandle, resolvePkColumn } from "./store/sql-session.ts";
|
|
95
98
|
export type {
|
|
96
99
|
SqlStoreHandle,
|
|
@@ -102,6 +105,8 @@ export type {
|
|
|
102
105
|
InsertValuesBuilder,
|
|
103
106
|
SqlSessionOptions,
|
|
104
107
|
SqlPageOptions,
|
|
108
|
+
UpsertResult,
|
|
109
|
+
UpsertStatus,
|
|
105
110
|
WhereMap,
|
|
106
111
|
} from "./store/sql-session.ts";
|
|
107
112
|
|
package/src/index.ts
CHANGED
|
@@ -41,8 +41,11 @@ export {
|
|
|
41
41
|
PluginNeedsError,
|
|
42
42
|
assertPluginNeeds,
|
|
43
43
|
freezePrincipal,
|
|
44
|
+
installGracefulShutdown,
|
|
45
|
+
releaseInstanceLeases,
|
|
44
46
|
type OkeApp,
|
|
45
47
|
type OkeOptions,
|
|
48
|
+
type ReadyState,
|
|
46
49
|
type FlowDef,
|
|
47
50
|
type FlowFailure,
|
|
48
51
|
type FlowErrorValue,
|
|
@@ -65,6 +68,9 @@ export {
|
|
|
65
68
|
now,
|
|
66
69
|
defineTable,
|
|
67
70
|
field,
|
|
71
|
+
defineSeed,
|
|
72
|
+
normalizeSeedFns,
|
|
73
|
+
resolveSeedCategory,
|
|
68
74
|
createStoreRuntime,
|
|
69
75
|
type StoreDecl,
|
|
70
76
|
type StoreRuntime,
|
|
@@ -72,6 +78,12 @@ export {
|
|
|
72
78
|
type TableHandle,
|
|
73
79
|
type SchemaTableDecl,
|
|
74
80
|
type SchemaColumnDecl,
|
|
81
|
+
type SeedDef,
|
|
82
|
+
type SeedFn,
|
|
83
|
+
type SeedFns,
|
|
84
|
+
type SeedCategory,
|
|
85
|
+
type UpsertResult,
|
|
86
|
+
type UpsertStatus,
|
|
75
87
|
} from "./elements/store.ts";
|
|
76
88
|
|
|
77
89
|
export {
|
|
@@ -152,7 +164,13 @@ export {
|
|
|
152
164
|
createJournal,
|
|
153
165
|
createMemoryJournalStore,
|
|
154
166
|
createFileJournalStore,
|
|
167
|
+
hasJournalLease,
|
|
168
|
+
isJournalLeaseBusy,
|
|
169
|
+
JournalLeaseBusy,
|
|
170
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
155
171
|
type Journal,
|
|
172
|
+
type JournalLeaseOptions,
|
|
173
|
+
type JournalLeaseStore,
|
|
156
174
|
type JournalStore,
|
|
157
175
|
type JournalRun,
|
|
158
176
|
} from "./kernel/journal.ts";
|
package/src/kernel/app.ts
CHANGED
|
@@ -71,9 +71,15 @@ import {
|
|
|
71
71
|
import {
|
|
72
72
|
createJournal,
|
|
73
73
|
createMemoryJournalStore,
|
|
74
|
+
hasJournalLease,
|
|
75
|
+
isJournalLeaseBusy,
|
|
74
76
|
isJournalSuspend,
|
|
77
|
+
JOURNAL_DEFAULT_LEASE_MS,
|
|
75
78
|
type JournalSession,
|
|
79
|
+
type JournalStore,
|
|
76
80
|
} from "./journal.ts";
|
|
81
|
+
import { releaseInstanceLeases } from "./graceful-shutdown.ts";
|
|
82
|
+
import type { JournalRuntime } from "./boot-bind/journal.ts";
|
|
77
83
|
import { listBindings, resetBindings, type Binding } from "./on.ts";
|
|
78
84
|
import {
|
|
79
85
|
applyPrincipal,
|
|
@@ -190,8 +196,13 @@ export interface OkeOptions {
|
|
|
190
196
|
readonly startScheduler?: boolean;
|
|
191
197
|
/** Scheduler tick period ms (default 1000). Forwarded to boot. */
|
|
192
198
|
readonly schedulerIntervalMs?: number;
|
|
199
|
+
/** Durable-run lease duration ms (default 30_000). Forwarded to boot. */
|
|
200
|
+
readonly journalLeaseMs?: number;
|
|
193
201
|
}
|
|
194
202
|
|
|
203
|
+
/** Readiness probe state — see `GET /_/ready`. */
|
|
204
|
+
export type ReadyState = "booting" | "orphan_scan" | "ready";
|
|
205
|
+
|
|
195
206
|
/** Payload for a CDC invocation. */
|
|
196
207
|
export interface CdcPayload {
|
|
197
208
|
readonly before: Record<string, unknown> | null;
|
|
@@ -250,6 +261,11 @@ export interface OkeApp<D extends Record<string, unknown> = {}, R extends AppRou
|
|
|
250
261
|
readonly plugins: PluginRegistry;
|
|
251
262
|
/** Whether {@link OkeApp.boot} has completed. */
|
|
252
263
|
readonly booted: boolean;
|
|
264
|
+
/**
|
|
265
|
+
* Readiness for probes — `booting` until boot returns, `orphan_scan` while
|
|
266
|
+
* the durable orphan resume runs, then `ready`. See `GET /_/ready`.
|
|
267
|
+
*/
|
|
268
|
+
readonly readyState: ReadyState;
|
|
253
269
|
/** Result of {@link OkeApp.boot} (element runtimes, capabilities). */
|
|
254
270
|
readonly bootResult: BootResult | undefined;
|
|
255
271
|
/**
|
|
@@ -558,6 +574,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
558
574
|
// --- boot (vault → store → signal → clock → channel → AI → runs → caps) ---
|
|
559
575
|
let bootResult: BootResult | undefined;
|
|
560
576
|
let bootPromise: Promise<BootResult> | undefined;
|
|
577
|
+
let readyState: ReadyState = "booting";
|
|
561
578
|
let bootEnv: BootOptions["env"] = options.env ?? "local";
|
|
562
579
|
let authBinding: AppAuthBinding | undefined =
|
|
563
580
|
gateConfig.auth !== undefined
|
|
@@ -579,13 +596,37 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
579
596
|
} else {
|
|
580
597
|
setActiveGateAuthContext(undefined);
|
|
581
598
|
}
|
|
582
|
-
|
|
599
|
+
// Fallback journal for pre-boot / `autoBoot: false` unit tests. A booted
|
|
600
|
+
// app replaces this with the bound `drivers.journal` store (postgres etc.).
|
|
601
|
+
const fallbackJournalStore = createMemoryJournalStore();
|
|
602
|
+
const fallbackJournalInstanceId = `app-${crypto.randomUUID()}`;
|
|
583
603
|
const sleepingRuns = new Map<
|
|
584
604
|
string,
|
|
585
605
|
{ readonly flow: AnyFlowDef; readonly input: unknown; readonly wakeAt: number }
|
|
586
606
|
>();
|
|
607
|
+
// Same-process mutual exclusion per runId. The lease coordinates across
|
|
608
|
+
// processes, but same-holder renew cannot distinguish two overlapping
|
|
609
|
+
// sessions inside one process (boot orphan scan vs. scheduler tick).
|
|
610
|
+
const inflightRuns = new Set<string>();
|
|
587
611
|
const EMPTY_CAPABILITIES: ReadonlyMap<string, CapabilityToken> = new Map();
|
|
588
612
|
|
|
613
|
+
/** Active journal: the boot-bound store once available, else the fallback. */
|
|
614
|
+
function activeJournal(): {
|
|
615
|
+
readonly store: JournalStore;
|
|
616
|
+
readonly instanceId: string;
|
|
617
|
+
readonly leaseMs: number;
|
|
618
|
+
} {
|
|
619
|
+
const bound: JournalRuntime | undefined = bootResult?.journal;
|
|
620
|
+
if (bound) {
|
|
621
|
+
return { store: bound.store, instanceId: bound.instanceId, leaseMs: bound.leaseMs };
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
store: fallbackJournalStore,
|
|
625
|
+
instanceId: fallbackJournalInstanceId,
|
|
626
|
+
leaseMs: JOURNAL_DEFAULT_LEASE_MS,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
589
630
|
async function handleCronFire(name: string): Promise<void> {
|
|
590
631
|
await app.dispatchEvery(name);
|
|
591
632
|
// Named clocks (e.g. `clock("expire-stale", { every: "1h" })`) reconcile
|
|
@@ -600,6 +641,84 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
600
641
|
await app.dispatchSignal(name, payload);
|
|
601
642
|
}
|
|
602
643
|
|
|
644
|
+
async function handleDurableResume(): Promise<void> {
|
|
645
|
+
await app.resumeDurable();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Element runtimes handed to durable resumes (same bag as cron dispatch). */
|
|
649
|
+
function durableResumeFx() {
|
|
650
|
+
return {
|
|
651
|
+
storeRuntime: bootResult?.store,
|
|
652
|
+
signalRuntime: bootResult?.signal,
|
|
653
|
+
vaultRuntime: bootResult?.vault,
|
|
654
|
+
channelRuntime: bootResult?.channel,
|
|
655
|
+
aiRuntime: bootResult?.ai,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Resume one persisted run under its lease. A lost lease race is the
|
|
661
|
+
* coordination win — another live instance owns the run, so skip quietly.
|
|
662
|
+
*/
|
|
663
|
+
/** Runs whose undeclared flow was already logged (sweep ticks must not spam). */
|
|
664
|
+
const warnedOrphanRuns = new Set<string>();
|
|
665
|
+
|
|
666
|
+
async function resumeDurableRun(
|
|
667
|
+
runId: string,
|
|
668
|
+
flowName: string,
|
|
669
|
+
input: unknown,
|
|
670
|
+
now: () => number,
|
|
671
|
+
): Promise<void> {
|
|
672
|
+
// The lease only coordinates across processes — within one process,
|
|
673
|
+
// overlapping callers (orphan scan vs. scheduler tick) would both pass
|
|
674
|
+
// same-holder lease renewal and run two sessions for one runId.
|
|
675
|
+
if (inflightRuns.has(runId)) return;
|
|
676
|
+
inflightRuns.add(runId);
|
|
677
|
+
try {
|
|
678
|
+
const flowDef = flowsByName.get(flowName);
|
|
679
|
+
if (!flowDef) {
|
|
680
|
+
if (!warnedOrphanRuns.has(runId)) {
|
|
681
|
+
warnedOrphanRuns.add(runId);
|
|
682
|
+
console.warn(`oke: durable run "${runId}": undeclared flow "${flowName}" — skipped`);
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
687
|
+
try {
|
|
688
|
+
await runDurable({
|
|
689
|
+
flow: flowDef,
|
|
690
|
+
input,
|
|
691
|
+
journalStore: store,
|
|
692
|
+
runId,
|
|
693
|
+
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
694
|
+
now,
|
|
695
|
+
fx: durableResumeFx(),
|
|
696
|
+
});
|
|
697
|
+
} catch (err) {
|
|
698
|
+
if (isJournalLeaseBusy(err)) return;
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
} finally {
|
|
702
|
+
inflightRuns.delete(runId);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Boot-time orphan scan — `running`/`sleeping` runs with no live lease. */
|
|
707
|
+
async function resumeOrphanedDurableRuns(): Promise<void> {
|
|
708
|
+
const { store } = activeJournal();
|
|
709
|
+
if (!hasJournalLease(store)) return;
|
|
710
|
+
const now = () => bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
711
|
+
const orphans = await store.listOrphans(now());
|
|
712
|
+
for (const orphan of orphans) {
|
|
713
|
+
// Future sleeps are claimed by the scheduler tick when they come due —
|
|
714
|
+
// the shared store is the schedule, no in-process seeding needed.
|
|
715
|
+
if (orphan.status === "sleeping" && orphan.wakeAt !== undefined && orphan.wakeAt > now()) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
await resumeDurableRun(orphan.id, orphan.flow, orphan.input, now);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
603
722
|
async function doBoot(overrides?: Partial<BootOptions>): Promise<BootResult> {
|
|
604
723
|
const { bootApplication, resolveElementNeeds } = await import("./boot.ts");
|
|
605
724
|
const { assertHttpGatePosture } = await import("../elements/gate/boot.ts");
|
|
@@ -716,13 +835,39 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
716
835
|
instanceId: overrides?.instanceId,
|
|
717
836
|
startScheduler: overrides?.startScheduler ?? options.startScheduler,
|
|
718
837
|
schedulerIntervalMs: overrides?.schedulerIntervalMs ?? options.schedulerIntervalMs,
|
|
838
|
+
journalLeaseMs: overrides?.journalLeaseMs ?? options.journalLeaseMs,
|
|
719
839
|
bindings: adopted,
|
|
720
840
|
flows: [...flowsByName.values()],
|
|
721
841
|
onCronFire: overrides?.onCronFire ?? handleCronFire,
|
|
722
842
|
onSignal: overrides?.onSignal ?? handleSignalFire,
|
|
843
|
+
onDurableResume: overrides?.onDurableResume ?? handleDurableResume,
|
|
723
844
|
};
|
|
724
845
|
const result = await bootApplication(merged);
|
|
725
846
|
bootResult = result;
|
|
847
|
+
// otp() Tier 1 / Tier 2 capability — fail loud at boot, never silent downgrade.
|
|
848
|
+
if (result.channel) {
|
|
849
|
+
const { assertOtpPluginCapability } = await import("../auth/otp-capability.ts");
|
|
850
|
+
for (const entry of pluginRegistry.installed) {
|
|
851
|
+
if (entry.plugin.name === "otp") {
|
|
852
|
+
assertOtpPluginCapability(entry.plugin.configSnapshot, result.channel.drivers);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// Boot-time orphan discovery: resume/schedule any `running` / `sleeping`
|
|
857
|
+
// run left without a live lease by a crashed (or previous) instance.
|
|
858
|
+
// Does not block boot return — readiness stays `orphan_scan` until done.
|
|
859
|
+
if (result.journal && hasJournalLease(result.journal.store)) {
|
|
860
|
+
readyState = "orphan_scan";
|
|
861
|
+
void resumeOrphanedDurableRuns()
|
|
862
|
+
.catch((err) => {
|
|
863
|
+
console.error("oke: durable orphan scan failed", err);
|
|
864
|
+
})
|
|
865
|
+
.finally(() => {
|
|
866
|
+
if (bootResult === result) readyState = "ready";
|
|
867
|
+
});
|
|
868
|
+
} else {
|
|
869
|
+
readyState = "ready";
|
|
870
|
+
}
|
|
726
871
|
// Prefer the booted clock for access-token expiry checks.
|
|
727
872
|
if (authBinding) {
|
|
728
873
|
authBinding = createAppAuthBinding({
|
|
@@ -908,8 +1053,16 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
908
1053
|
capability = booted.capabilities.get(flowDef.name);
|
|
909
1054
|
|
|
910
1055
|
if (flowDef.durable) {
|
|
911
|
-
const
|
|
1056
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
1057
|
+
const journal = createJournal({
|
|
1058
|
+
store,
|
|
1059
|
+
now,
|
|
1060
|
+
// Hold the run lease for the request's lifetime — a crash mid-run
|
|
1061
|
+
// leaves an expired lease another instance can reclaim and resume.
|
|
1062
|
+
...(hasJournalLease(store) ? { lease: { instanceId, leaseMs } } : {}),
|
|
1063
|
+
});
|
|
912
1064
|
journalSession = await journal.start(flowDef.name, input);
|
|
1065
|
+
inflightRuns.add(journalSession.runId);
|
|
913
1066
|
}
|
|
914
1067
|
}
|
|
915
1068
|
|
|
@@ -998,25 +1151,34 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
998
1151
|
// HTTP flows serialize before `onResponse` so the last stage can see
|
|
999
1152
|
// and replace the final response (plugin header/middleware surfaces).
|
|
1000
1153
|
trigger.kind === "http" ? encodeExecuteResult : undefined,
|
|
1001
|
-
)
|
|
1154
|
+
).catch((err: unknown) => {
|
|
1155
|
+
// Park suspensions are already absorbed above; a real pipeline failure
|
|
1156
|
+
// still leaves the run lease to expire for cross-process reclaim, but
|
|
1157
|
+
// must not pin the runId in this process's in-flight guard forever.
|
|
1158
|
+
if (journalSession) inflightRuns.delete(journalSession.runId);
|
|
1159
|
+
throw err;
|
|
1160
|
+
});
|
|
1002
1161
|
|
|
1003
1162
|
const endedAt = now();
|
|
1004
1163
|
|
|
1005
1164
|
if (journalSession) {
|
|
1165
|
+
inflightRuns.delete(journalSession.runId);
|
|
1006
1166
|
const sleeping = ctx.state.sleeping as
|
|
1007
1167
|
| { readonly wakeAt: number; readonly label: string; readonly runId: string }
|
|
1008
1168
|
| undefined;
|
|
1009
|
-
|
|
1169
|
+
// Lease-capable stores make the shared row the wake schedule; the
|
|
1170
|
+
// in-process map is only for custom stores without the lease surface.
|
|
1171
|
+
if (sleeping && !hasJournalLease(activeJournal().store)) {
|
|
1010
1172
|
sleepingRuns.set(sleeping.runId, {
|
|
1011
1173
|
flow: flowDef,
|
|
1012
1174
|
input: ctx.input,
|
|
1013
1175
|
wakeAt: sleeping.wakeAt,
|
|
1014
1176
|
});
|
|
1015
|
-
} else if (result.failure) {
|
|
1177
|
+
} else if (!sleeping && result.failure) {
|
|
1016
1178
|
await journalSession.commit("failed", {
|
|
1017
1179
|
error: result.failure.error.code,
|
|
1018
1180
|
});
|
|
1019
|
-
} else {
|
|
1181
|
+
} else if (!sleeping) {
|
|
1020
1182
|
await journalSession.commit("completed", { output: result.output });
|
|
1021
1183
|
}
|
|
1022
1184
|
}
|
|
@@ -1060,6 +1222,9 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1060
1222
|
get booted() {
|
|
1061
1223
|
return bootResult !== undefined;
|
|
1062
1224
|
},
|
|
1225
|
+
get readyState() {
|
|
1226
|
+
return readyState;
|
|
1227
|
+
},
|
|
1063
1228
|
get bootResult() {
|
|
1064
1229
|
return bootResult;
|
|
1065
1230
|
},
|
|
@@ -1074,6 +1239,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1074
1239
|
channel: bootResult.channel,
|
|
1075
1240
|
ai: bootResult.ai,
|
|
1076
1241
|
runs: bootResult.runs,
|
|
1242
|
+
...(bootResult.journal ? { journal: bootResult.journal } : {}),
|
|
1077
1243
|
};
|
|
1078
1244
|
},
|
|
1079
1245
|
get capabilities() {
|
|
@@ -1090,31 +1256,54 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1090
1256
|
async stop() {
|
|
1091
1257
|
if (!bootResult) return;
|
|
1092
1258
|
bootResult.stopScheduler();
|
|
1259
|
+
// Proactive lease release so survivors need not wait for TTL reclaim.
|
|
1260
|
+
await releaseInstanceLeases({
|
|
1261
|
+
bootResult: {
|
|
1262
|
+
clock: bootResult.clock,
|
|
1263
|
+
journal: bootResult.journal,
|
|
1264
|
+
},
|
|
1265
|
+
stop: async () => {},
|
|
1266
|
+
});
|
|
1093
1267
|
await bootResult.close();
|
|
1094
1268
|
bootResult = undefined;
|
|
1095
1269
|
bootPromise = undefined;
|
|
1270
|
+
readyState = "booting";
|
|
1096
1271
|
},
|
|
1097
1272
|
get authBinding() {
|
|
1098
1273
|
return authBinding;
|
|
1099
1274
|
},
|
|
1100
1275
|
async resumeDurable(now) {
|
|
1101
1276
|
const t = now ?? bootResult?.clock?.now() ?? options.fx?.now?.() ?? Date.now();
|
|
1277
|
+
const { store, instanceId, leaseMs } = activeJournal();
|
|
1278
|
+
if (hasJournalLease(store)) {
|
|
1279
|
+
// Shared store is the wake schedule — claim due sleeps across all
|
|
1280
|
+
// instances (SKIP LOCKED; exactly one claimant wins a raced claim).
|
|
1281
|
+
for (;;) {
|
|
1282
|
+
const due = await store.claimDueSleep(instanceId, t, leaseMs);
|
|
1283
|
+
if (!due) break;
|
|
1284
|
+
sleepingRuns.delete(due.id);
|
|
1285
|
+
await resumeDurableRun(due.id, due.flow, due.input, () => t);
|
|
1286
|
+
}
|
|
1287
|
+
// Crash sweep: the boot orphan scan is once-only, so each tick also
|
|
1288
|
+
// reclaims `running` runs whose holder's lease has expired (same
|
|
1289
|
+
// takeover physics as Clock: lease expiry + next tick).
|
|
1290
|
+
for (const orphan of await store.listOrphans(t)) {
|
|
1291
|
+
if (orphan.status !== "running") continue;
|
|
1292
|
+
await resumeDurableRun(orphan.id, orphan.flow, orphan.input, () => t);
|
|
1293
|
+
}
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
// Custom store without the lease surface — in-process sleepers only.
|
|
1102
1297
|
for (const [runId, sleeper] of [...sleepingRuns.entries()]) {
|
|
1103
1298
|
if (t < sleeper.wakeAt) continue;
|
|
1104
1299
|
sleepingRuns.delete(runId);
|
|
1105
1300
|
const result = await runDurable({
|
|
1106
1301
|
flow: sleeper.flow,
|
|
1107
1302
|
input: sleeper.input,
|
|
1108
|
-
journalStore,
|
|
1303
|
+
journalStore: store,
|
|
1109
1304
|
runId,
|
|
1110
1305
|
now: () => t,
|
|
1111
|
-
fx:
|
|
1112
|
-
storeRuntime: bootResult?.store,
|
|
1113
|
-
signalRuntime: bootResult?.signal,
|
|
1114
|
-
vaultRuntime: bootResult?.vault,
|
|
1115
|
-
channelRuntime: bootResult?.channel,
|
|
1116
|
-
aiRuntime: bootResult?.ai,
|
|
1117
|
-
},
|
|
1306
|
+
fx: durableResumeFx(),
|
|
1118
1307
|
});
|
|
1119
1308
|
if (result.status === "sleeping") {
|
|
1120
1309
|
sleepingRuns.set(runId, {
|
|
@@ -1196,6 +1385,24 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
1196
1385
|
return response;
|
|
1197
1386
|
};
|
|
1198
1387
|
|
|
1388
|
+
// Kernel readiness — distinct from app-authored GET /health (liveness).
|
|
1389
|
+
if (method === "GET" && url.pathname === "/_/ready") {
|
|
1390
|
+
if (readyState === "ready") {
|
|
1391
|
+
return respond(
|
|
1392
|
+
new Response(JSON.stringify({ ready: true }), {
|
|
1393
|
+
status: 200,
|
|
1394
|
+
headers: { "content-type": "application/json" },
|
|
1395
|
+
}),
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
return respond(
|
|
1399
|
+
new Response(JSON.stringify({ ready: false, reason: readyState }), {
|
|
1400
|
+
status: 503,
|
|
1401
|
+
headers: { "content-type": "application/json" },
|
|
1402
|
+
}),
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1199
1406
|
if (method === "GET" && url.pathname === "/_oke/client.json") {
|
|
1200
1407
|
return respond(
|
|
1201
1408
|
new Response(JSON.stringify(routes), {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveSmsDriverId,
|
|
11
11
|
smtpOptionsFromEnv,
|
|
12
12
|
sndrOptionsFromEnv,
|
|
13
|
+
taqnyatMailOptionsFromEnv,
|
|
13
14
|
taqnyatOptionsFromEnv,
|
|
14
15
|
unifonicOptionsFromEnv,
|
|
15
16
|
} from "./channel.ts";
|
|
@@ -24,6 +25,8 @@ const previous = {
|
|
|
24
25
|
sndrBase: process.env.SNDR_BASE_URL,
|
|
25
26
|
taqBearer: process.env.TAQNYAT_BEARER_TOKEN,
|
|
26
27
|
taqSender: process.env.TAQNYAT_SENDER,
|
|
28
|
+
taqMailToken: process.env.TAQNYAT_MAIL_TOKEN,
|
|
29
|
+
taqCampaign: process.env.TAQNYAT_CAMPAIGN,
|
|
27
30
|
msegatUser: process.env.MSEGAT_USERNAME,
|
|
28
31
|
msegatKey: process.env.MSEGAT_API_KEY,
|
|
29
32
|
msegatSender: process.env.MSEGAT_SENDER,
|
|
@@ -41,6 +44,8 @@ afterEach(() => {
|
|
|
41
44
|
restoreEnv("SNDR_BASE_URL", previous.sndrBase);
|
|
42
45
|
restoreEnv("TAQNYAT_BEARER_TOKEN", previous.taqBearer);
|
|
43
46
|
restoreEnv("TAQNYAT_SENDER", previous.taqSender);
|
|
47
|
+
restoreEnv("TAQNYAT_MAIL_TOKEN", previous.taqMailToken);
|
|
48
|
+
restoreEnv("TAQNYAT_CAMPAIGN", previous.taqCampaign);
|
|
44
49
|
restoreEnv("MSEGAT_USERNAME", previous.msegatUser);
|
|
45
50
|
restoreEnv("MSEGAT_API_KEY", previous.msegatKey);
|
|
46
51
|
restoreEnv("MSEGAT_SENDER", previous.msegatSender);
|
|
@@ -117,6 +122,17 @@ describe("bindChannel driver resolution", () => {
|
|
|
117
122
|
process.env.UNIFONIC_SENDER = "Brand";
|
|
118
123
|
expect(unifonicOptionsFromEnv()).toEqual({ appSid: "sid", sender: "Brand" });
|
|
119
124
|
});
|
|
125
|
+
|
|
126
|
+
test("taqnyat-mail env helper", () => {
|
|
127
|
+
process.env.TAQNYAT_MAIL_TOKEN = "bearer-mail";
|
|
128
|
+
process.env.TAQNYAT_CAMPAIGN = "auth";
|
|
129
|
+
expect(taqnyatMailOptionsFromEnv()).toEqual({
|
|
130
|
+
bearerToken: "bearer-mail",
|
|
131
|
+
campaignName: "auth",
|
|
132
|
+
});
|
|
133
|
+
delete process.env.TAQNYAT_CAMPAIGN;
|
|
134
|
+
expect(() => taqnyatMailOptionsFromEnv()).toThrow("TAQNYAT_CAMPAIGN");
|
|
135
|
+
});
|
|
120
136
|
});
|
|
121
137
|
|
|
122
138
|
function restoreEnv(key: string, value: string | undefined): void {
|
|
@@ -9,7 +9,10 @@ import { openResendChannel } from "../../drivers/channel-resend.ts";
|
|
|
9
9
|
import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
|
|
10
10
|
import { openSndrChannel } from "../../drivers/channel-sndr.ts";
|
|
11
11
|
import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
|
|
12
|
+
import { openTaqnyatMailChannel } from "../../drivers/channel-taqnyat-mail.ts";
|
|
13
|
+
import { openTaqnyatWhatsAppChannel } from "../../drivers/channel-taqnyat-whatsapp.ts";
|
|
12
14
|
import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
|
|
15
|
+
import { openWaCloudChannel } from "../../drivers/channel-wa-cloud.ts";
|
|
13
16
|
import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
|
|
14
17
|
import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
|
|
15
18
|
import type { BootOptions } from "../boot.ts";
|
|
@@ -42,6 +45,8 @@ function defaultDrivers(options: BootOptions, env: ConfigEnv, docker: boolean):
|
|
|
42
45
|
const drivers: ChannelDriver[] = [emailDriverFor(options, env, docker)];
|
|
43
46
|
const sms = smsDriverFor(options, env);
|
|
44
47
|
if (sms) drivers.push(sms);
|
|
48
|
+
const whatsapp = whatsappDriverFor(options, env);
|
|
49
|
+
if (whatsapp) drivers.push(whatsapp);
|
|
45
50
|
return drivers;
|
|
46
51
|
}
|
|
47
52
|
|
|
@@ -51,6 +56,7 @@ function emailDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean):
|
|
|
51
56
|
if (id === "smtp") return openSmtpChannel(smtpOptionsFromEnv(docker));
|
|
52
57
|
if (id === "resend") return openResendChannel(resendOptionsFromEnv());
|
|
53
58
|
if (id === "sndr") return openSndrChannel(sndrOptionsFromEnv());
|
|
59
|
+
if (id === "taqnyat-mail") return openTaqnyatMailChannel(taqnyatMailOptionsFromEnv());
|
|
54
60
|
throw new Error(`oke boot: unknown email channel driver "${id}"`);
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -90,6 +96,53 @@ export function resolveSmsDriverId(options: BootOptions, env: ConfigEnv): string
|
|
|
90
96
|
return resolveDriverId(options.config?.drivers?.channel?.sms, env);
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Resolve the configured WhatsApp driver for one environment.
|
|
101
|
+
*
|
|
102
|
+
* @param options - Boot options
|
|
103
|
+
* @param env - Active environment
|
|
104
|
+
*/
|
|
105
|
+
export function resolveWhatsappDriverId(options: BootOptions, env: ConfigEnv): string | undefined {
|
|
106
|
+
return resolveDriverId(options.config?.drivers?.channel?.whatsapp, env);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function whatsappDriverFor(options: BootOptions, env: ConfigEnv): ChannelDriver | undefined {
|
|
110
|
+
const id = resolveWhatsappDriverId(options, env);
|
|
111
|
+
if (!id || id === "console") return undefined;
|
|
112
|
+
if (id === "taqnyat-whatsapp") return openTaqnyatWhatsAppChannel(taqnyatWhatsAppOptionsFromEnv());
|
|
113
|
+
if (id === "wa-cloud") return openWaCloudChannel(waCloudOptionsFromEnv());
|
|
114
|
+
throw new Error(`oke boot: unknown whatsapp channel driver "${id}"`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve Taqnyat WhatsApp options from env.
|
|
119
|
+
*/
|
|
120
|
+
export function taqnyatWhatsAppOptionsFromEnv(): ChannelOpenOptions {
|
|
121
|
+
const bearerToken =
|
|
122
|
+
process.env.TAQNYAT_WHATSAPP_TOKEN?.trim() ??
|
|
123
|
+
process.env.TAQNYAT_BEARER_TOKEN?.trim() ??
|
|
124
|
+
process.env.TAQNYAT_TOKEN?.trim();
|
|
125
|
+
if (!bearerToken) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"oke boot: taqnyat-whatsapp channel needs TAQNYAT_WHATSAPP_TOKEN (or TAQNYAT_BEARER_TOKEN)",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { bearerToken };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve WhatsApp Cloud API options from env.
|
|
135
|
+
*/
|
|
136
|
+
export function waCloudOptionsFromEnv(): ChannelOpenOptions {
|
|
137
|
+
const token = process.env.WHATSAPP_TOKEN?.trim() ?? process.env.WA_CLOUD_TOKEN?.trim();
|
|
138
|
+
const from =
|
|
139
|
+
process.env.WHATSAPP_PHONE_NUMBER_ID?.trim() ?? process.env.WA_CLOUD_PHONE_NUMBER_ID?.trim();
|
|
140
|
+
if (!token || !from) {
|
|
141
|
+
throw new Error("oke boot: wa-cloud channel needs WHATSAPP_TOKEN and WHATSAPP_PHONE_NUMBER_ID");
|
|
142
|
+
}
|
|
143
|
+
return { token, from };
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
/**
|
|
94
147
|
* Resolve SMTP connection options from `SMTP_URL` plus optional auth overrides.
|
|
95
148
|
*
|
|
@@ -150,6 +203,17 @@ export function taqnyatOptionsFromEnv(): ChannelOpenOptions {
|
|
|
150
203
|
return { bearerToken, sender };
|
|
151
204
|
}
|
|
152
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Resolve Taqnyat Email options from env.
|
|
208
|
+
*/
|
|
209
|
+
export function taqnyatMailOptionsFromEnv(): ChannelOpenOptions {
|
|
210
|
+
const bearerToken = process.env.TAQNYAT_MAIL_TOKEN?.trim();
|
|
211
|
+
const campaignName = process.env.TAQNYAT_CAMPAIGN?.trim();
|
|
212
|
+
if (!bearerToken) throw new Error("oke boot: taqnyat-mail channel needs TAQNYAT_MAIL_TOKEN");
|
|
213
|
+
if (!campaignName) throw new Error("oke boot: taqnyat-mail channel needs TAQNYAT_CAMPAIGN");
|
|
214
|
+
return { bearerToken, campaignName };
|
|
215
|
+
}
|
|
216
|
+
|
|
153
217
|
/**
|
|
154
218
|
* Resolve Msegat SMS options from env.
|
|
155
219
|
*/
|