@pylonsync/functions 0.19.1 → 0.21.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/dist/define.d.ts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/response.d.ts +39 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/ssr-runtime.d.ts +7 -0
- package/dist/types.d.ts +202 -8
- package/dist/workflows.d.ts +19 -2
- package/package.json +1 -1
- package/src/define.ts +2 -1
- package/src/index.ts +7 -0
- package/src/response.test.ts +42 -0
- package/src/response.ts +109 -0
- package/src/runtime.ts +70 -5
- package/src/ssr-runtime.test.ts +25 -0
- package/src/ssr-runtime.ts +35 -3
- package/src/types.ts +211 -9
- package/src/workflows.test.ts +74 -7
- package/src/workflows.ts +81 -19
package/src/runtime.ts
CHANGED
|
@@ -33,6 +33,9 @@ import type {
|
|
|
33
33
|
LlmStreamEvent,
|
|
34
34
|
Rooms,
|
|
35
35
|
Workflows,
|
|
36
|
+
WorkflowRunSummary,
|
|
37
|
+
Audit,
|
|
38
|
+
AuditEntry,
|
|
36
39
|
Connections,
|
|
37
40
|
Domains,
|
|
38
41
|
TenantDomainResult,
|
|
@@ -47,6 +50,7 @@ import type {
|
|
|
47
50
|
} from "./types";
|
|
48
51
|
import { normalizeAuthClaims } from "./auth";
|
|
49
52
|
import { makeRequireMember } from "./member";
|
|
53
|
+
import { response } from "./response";
|
|
50
54
|
import { isDevMode } from "./ssr-runtime";
|
|
51
55
|
import { validateArgs } from "./validators";
|
|
52
56
|
import { serverBundle } from "./server-bundle";
|
|
@@ -840,10 +844,10 @@ function buildScheduler(callId: string): Scheduler {
|
|
|
840
844
|
return r.id || "";
|
|
841
845
|
},
|
|
842
846
|
async cancel(scheduleId) {
|
|
843
|
-
await rpc(callId, {
|
|
847
|
+
return (await rpc(callId, {
|
|
844
848
|
type: "cancel_schedule",
|
|
845
849
|
schedule_id: scheduleId,
|
|
846
|
-
});
|
|
850
|
+
})) as { cancelled: boolean };
|
|
847
851
|
},
|
|
848
852
|
};
|
|
849
853
|
}
|
|
@@ -985,15 +989,47 @@ export function buildRooms(callId: string): Rooms {
|
|
|
985
989
|
* takes it from there, so `start` returns the instance id immediately.
|
|
986
990
|
* Uses the queued call_id-keyed `rpc` (the reply carries no op_id).
|
|
987
991
|
*/
|
|
992
|
+
/** Build `ctx.audit` — round-trips `audit_op` frames to the host. */
|
|
993
|
+
export function buildAudit(callId: string): Audit {
|
|
994
|
+
return {
|
|
995
|
+
async log(event) {
|
|
996
|
+
return (await rpc(callId, {
|
|
997
|
+
type: "audit_op",
|
|
998
|
+
op: "log",
|
|
999
|
+
action: event.action,
|
|
1000
|
+
entity: event.entity ?? null,
|
|
1001
|
+
entity_id: event.entityId ?? null,
|
|
1002
|
+
subject: event.subject ?? null,
|
|
1003
|
+
meta: event.meta ?? null,
|
|
1004
|
+
})) as { id: string };
|
|
1005
|
+
},
|
|
1006
|
+
async list(filter) {
|
|
1007
|
+
return (await rpc(callId, {
|
|
1008
|
+
type: "audit_op",
|
|
1009
|
+
op: "list",
|
|
1010
|
+
action: filter?.action ?? null,
|
|
1011
|
+
entity: filter?.entity ?? null,
|
|
1012
|
+
entity_id: filter?.entityId ?? null,
|
|
1013
|
+
actor: filter?.actor ?? null,
|
|
1014
|
+
tenant: filter?.tenant ?? null,
|
|
1015
|
+
before: filter?.before ?? null,
|
|
1016
|
+
before_id: filter?.beforeId ?? null,
|
|
1017
|
+
limit: filter?.limit ?? null,
|
|
1018
|
+
})) as AuditEntry[];
|
|
1019
|
+
},
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
988
1023
|
export function buildWorkflows(callId: string): Workflows {
|
|
989
1024
|
return {
|
|
990
|
-
async start(name: string, input?: unknown) {
|
|
1025
|
+
async start(name: string, input?: unknown, opts?: { key?: string }) {
|
|
991
1026
|
return (await rpc(callId, {
|
|
992
1027
|
type: "workflow_op",
|
|
993
1028
|
op: "start",
|
|
994
1029
|
name,
|
|
995
1030
|
input: input ?? null,
|
|
996
|
-
|
|
1031
|
+
key: opts?.key ?? null,
|
|
1032
|
+
})) as { id: string; created: boolean };
|
|
997
1033
|
},
|
|
998
1034
|
async sendEvent(workflowId: string, event: string, data?: unknown) {
|
|
999
1035
|
return (await rpc(callId, {
|
|
@@ -1002,7 +1038,32 @@ export function buildWorkflows(callId: string): Workflows {
|
|
|
1002
1038
|
workflow_id: workflowId,
|
|
1003
1039
|
event,
|
|
1004
1040
|
data: data ?? null,
|
|
1005
|
-
})) as { delivered: boolean };
|
|
1041
|
+
})) as { delivered: boolean; buffered: boolean };
|
|
1042
|
+
},
|
|
1043
|
+
async cancel(workflowId: string, opts?: { reason?: string }) {
|
|
1044
|
+
return (await rpc(callId, {
|
|
1045
|
+
type: "workflow_op",
|
|
1046
|
+
op: "cancel",
|
|
1047
|
+
workflow_id: workflowId,
|
|
1048
|
+
reason: opts?.reason ?? null,
|
|
1049
|
+
})) as { cancelled: boolean };
|
|
1050
|
+
},
|
|
1051
|
+
async get(workflowId: string) {
|
|
1052
|
+
return (await rpc(callId, {
|
|
1053
|
+
type: "workflow_op",
|
|
1054
|
+
op: "get",
|
|
1055
|
+
workflow_id: workflowId,
|
|
1056
|
+
})) as WorkflowRunSummary | null;
|
|
1057
|
+
},
|
|
1058
|
+
async list(filter) {
|
|
1059
|
+
return (await rpc(callId, {
|
|
1060
|
+
type: "workflow_op",
|
|
1061
|
+
op: "list",
|
|
1062
|
+
name: filter?.name ?? null,
|
|
1063
|
+
key: filter?.key ?? null,
|
|
1064
|
+
status: filter?.status ?? null,
|
|
1065
|
+
limit: filter?.limit ?? null,
|
|
1066
|
+
})) as WorkflowRunSummary[];
|
|
1006
1067
|
},
|
|
1007
1068
|
};
|
|
1008
1069
|
}
|
|
@@ -1166,6 +1227,7 @@ function buildActionCtx(
|
|
|
1166
1227
|
normalizedRequest = {
|
|
1167
1228
|
method: String(r.method ?? ""),
|
|
1168
1229
|
path: String(r.path ?? ""),
|
|
1230
|
+
url: String(r.url ?? ""),
|
|
1169
1231
|
headers: (r.headers as Record<string, string>) ?? {},
|
|
1170
1232
|
rawBody: String(r.raw_body ?? r.rawBody ?? ""),
|
|
1171
1233
|
};
|
|
@@ -1179,6 +1241,7 @@ function buildActionCtx(
|
|
|
1179
1241
|
rooms,
|
|
1180
1242
|
connections,
|
|
1181
1243
|
workflows: buildWorkflows(callId),
|
|
1244
|
+
audit: buildAudit(callId),
|
|
1182
1245
|
domains: buildDomains(),
|
|
1183
1246
|
env: process.env as Record<string, string>,
|
|
1184
1247
|
async runQuery(fnName, args) {
|
|
@@ -1214,6 +1277,7 @@ function buildActionCtx(
|
|
|
1214
1277
|
}) as Promise<any[]>,
|
|
1215
1278
|
),
|
|
1216
1279
|
request: normalizedRequest,
|
|
1280
|
+
response,
|
|
1217
1281
|
};
|
|
1218
1282
|
}
|
|
1219
1283
|
|
|
@@ -1372,6 +1436,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
|
|
|
1372
1436
|
rooms,
|
|
1373
1437
|
connections,
|
|
1374
1438
|
workflows: buildWorkflows(msg.call_id),
|
|
1439
|
+
audit: buildAudit(msg.call_id),
|
|
1375
1440
|
error(code, message) {
|
|
1376
1441
|
const err = new Error(message);
|
|
1377
1442
|
(err as any).code = code;
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
makeReadTrackingProxy,
|
|
21
21
|
makeRevocableReadTrackingProxy,
|
|
22
22
|
jsonClone,
|
|
23
|
+
isLoopbackHost,
|
|
23
24
|
} from "./ssr-runtime";
|
|
24
25
|
|
|
25
26
|
describe("resolveOrigin — Host-header allowlist (cache-poisoning fence)", () => {
|
|
@@ -904,3 +905,27 @@ describe("script-escaping + secure cookies", () => {
|
|
|
904
905
|
}
|
|
905
906
|
});
|
|
906
907
|
});
|
|
908
|
+
|
|
909
|
+
describe("isLoopbackHost — exact loopback match", () => {
|
|
910
|
+
test("accepts loopback names and addresses with an optional port", () => {
|
|
911
|
+
for (const h of ["localhost", "localhost:4321", "127.0.0.1", "127.1.2.3:80", "0.0.0.0:4321", "::1", "[::1]", "[::1]:4321"]) {
|
|
912
|
+
expect(isLoopbackHost(h)).toBe(true);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("rejects hosts that only start like loopback", () => {
|
|
917
|
+
for (const h of ["localhost.attacker.io", "127.attacker.test", "0.0.0.0.attacker.net", "localhostx", "[::1].evil.com", "localhost:80x", "128.0.0.1", "127.0.0.256", "127.000.000.001", "0:0:0:0:0:0:0:1", "localhost.", ""]) {
|
|
918
|
+
expect(isLoopbackHost(h)).toBe(false);
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("a look-alike Host is not trusted for the origin or a redirect", () => {
|
|
923
|
+
expect(
|
|
924
|
+
resolveOrigin({ host: "localhost.attacker.io", publicUrl: "https://app.example.com" }),
|
|
925
|
+
).toBe("https://app.example.com");
|
|
926
|
+
expect(
|
|
927
|
+
isSafeRedirect("https://localhost.attacker.io/phish", { publicUrl: "https://app.example.com" }),
|
|
928
|
+
).toBe(false);
|
|
929
|
+
expect(isSafeRedirect("http://localhost:4321/ok", {})).toBe(true);
|
|
930
|
+
});
|
|
931
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -1092,7 +1092,39 @@ function readSocialImageMeta(relPath: string): {
|
|
|
1092
1092
|
return { type, width, height, v };
|
|
1093
1093
|
}
|
|
1094
1094
|
|
|
1095
|
-
|
|
1095
|
+
/** Loopback host? `host` is a lowercase authority (`host[:port]`,
|
|
1096
|
+
* `[ipv6]:port`). True for exactly `localhost`, dotted 127.0.0.0/8
|
|
1097
|
+
* without leading zeros, `::1`, or `0.0.0.0`, with an optional numeric
|
|
1098
|
+
* port. The match is exact, so
|
|
1099
|
+
* `localhost.example.com` is not loopback. Mirrors `is_loopback_host` in
|
|
1100
|
+
* crates/router/src/public_url.rs. Exported for tests. */
|
|
1101
|
+
export function isLoopbackHost(host: string): boolean {
|
|
1102
|
+
let name: string;
|
|
1103
|
+
const bracketed = /^\[([^\]]*)\](?::(\d{1,5}))?$/.exec(host);
|
|
1104
|
+
if (bracketed) {
|
|
1105
|
+
name = bracketed[1];
|
|
1106
|
+
} else if (host.startsWith("[")) {
|
|
1107
|
+
return false;
|
|
1108
|
+
} else {
|
|
1109
|
+
const colons = host.split(":").length - 1;
|
|
1110
|
+
if (colons === 1) {
|
|
1111
|
+
const [h, port] = host.split(":");
|
|
1112
|
+
if (!/^\d{1,5}$/.test(port)) return false;
|
|
1113
|
+
name = h;
|
|
1114
|
+
} else {
|
|
1115
|
+
name = host;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (name === "localhost" || name === "::1" || name === "0.0.0.0") return true;
|
|
1119
|
+
// Dotted IPv4 without leading zeros (matches Rust's Ipv4Addr parser).
|
|
1120
|
+
const octet = "(0|[1-9]\\d{0,2})";
|
|
1121
|
+
const v4 = new RegExp(`^${octet}\\.${octet}\\.${octet}\\.${octet}$`).exec(name);
|
|
1122
|
+
if (v4) {
|
|
1123
|
+
const octets = v4.slice(1).map(Number);
|
|
1124
|
+
return octets.every((o) => o <= 255) && octets[0] === 127;
|
|
1125
|
+
}
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1096
1128
|
|
|
1097
1129
|
/** Normalize a bare host or a full URL down to a lowercase `host` (host:port).
|
|
1098
1130
|
* Returns "" for unparseable input. */
|
|
@@ -1135,7 +1167,7 @@ export function resolveOrigin(opts: {
|
|
|
1135
1167
|
add(opts.publicUrl || "");
|
|
1136
1168
|
add(opts.canonicalHost || "");
|
|
1137
1169
|
for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
|
|
1138
|
-
const isLoopback =
|
|
1170
|
+
const isLoopback = isLoopbackHost(host);
|
|
1139
1171
|
if (isLoopback || allow.has(host)) {
|
|
1140
1172
|
// Off-loopback (prod) we ALWAYS use https and never honor the request's
|
|
1141
1173
|
// X-Forwarded-Proto. The SSR cache is keyed only by host (not proto), so
|
|
@@ -1201,7 +1233,7 @@ export function isSafeRedirect(
|
|
|
1201
1233
|
}
|
|
1202
1234
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
1203
1235
|
const host = parsed.host.toLowerCase();
|
|
1204
|
-
if (
|
|
1236
|
+
if (isLoopbackHost(host)) return true;
|
|
1205
1237
|
const allow = new Set<string>();
|
|
1206
1238
|
const add = (v: string) => {
|
|
1207
1239
|
const h = hostOf(v);
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Type definitions for the function system.
|
|
3
3
|
*/
|
|
4
|
+
import type { RawResponse, RawResponseInit } from "./response";
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Auth
|
|
@@ -370,8 +371,21 @@ export interface Scheduler {
|
|
|
370
371
|
args: Record<string, unknown>
|
|
371
372
|
): Promise<string>;
|
|
372
373
|
|
|
373
|
-
/**
|
|
374
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Cancel a scheduled function that has not started. Pass the id
|
|
376
|
+
* `runAfter` or `runAt` returned.
|
|
377
|
+
*
|
|
378
|
+
* Resolves `{ cancelled: true }` when the job will not run, and
|
|
379
|
+
* `{ cancelled: false }` when the id is unknown or the job already
|
|
380
|
+
* started or finished (a running job is not interrupted). Rejects with
|
|
381
|
+
* `SCHEDULE_CANCEL_FAILED` when the cancel could not be saved; the job
|
|
382
|
+
* then still runs.
|
|
383
|
+
*
|
|
384
|
+
* Inside a mutation, the cancel commits or rolls back with the
|
|
385
|
+
* mutation: if the mutation throws, the job stays scheduled. From an
|
|
386
|
+
* action, it takes effect at once.
|
|
387
|
+
*/
|
|
388
|
+
cancel(scheduleId: string): Promise<{ cancelled: boolean }>;
|
|
375
389
|
}
|
|
376
390
|
|
|
377
391
|
/**
|
|
@@ -604,18 +618,162 @@ export interface Workflows {
|
|
|
604
618
|
/**
|
|
605
619
|
* Start a workflow instance by name. Returns immediately with the
|
|
606
620
|
* instance id — the engine's background driver executes the steps.
|
|
621
|
+
*
|
|
622
|
+
* With `key` (for example a lead id), at most one non-terminal run
|
|
623
|
+
* exists per workflow name and key. If one exists, no new run starts
|
|
624
|
+
* and its id is returned with `created: false`. A finished run frees
|
|
625
|
+
* the key.
|
|
607
626
|
*/
|
|
608
|
-
start(
|
|
627
|
+
start(
|
|
628
|
+
name: string,
|
|
629
|
+
input?: unknown,
|
|
630
|
+
opts?: { key?: string },
|
|
631
|
+
): Promise<{ id: string; created: boolean }>;
|
|
609
632
|
/**
|
|
610
|
-
*
|
|
611
|
-
* `wf.waitForEvent(event)
|
|
612
|
-
*
|
|
633
|
+
* Send an event to a run. If the run is waiting for it
|
|
634
|
+
* (`wf.waitForEvent(event)`), the run resumes and `delivered` is true.
|
|
635
|
+
* Otherwise the event is buffered (`buffered: true`) and the run's next
|
|
636
|
+
* `waitForEvent(event)` consumes it. Rejects when the run has finished
|
|
637
|
+
* or already holds 100 buffered events. Events sent without data
|
|
638
|
+
* arrive as `{}`.
|
|
613
639
|
*/
|
|
614
640
|
sendEvent(
|
|
615
641
|
workflowId: string,
|
|
616
642
|
event: string,
|
|
617
643
|
data?: unknown,
|
|
618
|
-
): Promise<{ delivered: boolean }>;
|
|
644
|
+
): Promise<{ delivered: boolean; buffered: boolean }>;
|
|
645
|
+
/**
|
|
646
|
+
* Cancel a run. No further step runs. A step already in progress may
|
|
647
|
+
* finish, but its result is discarded. Resolves `cancelled: false` when
|
|
648
|
+
* the run had already finished. Rejects for an unknown id.
|
|
649
|
+
*/
|
|
650
|
+
cancel(
|
|
651
|
+
workflowId: string,
|
|
652
|
+
opts?: { reason?: string },
|
|
653
|
+
): Promise<{ cancelled: boolean }>;
|
|
654
|
+
/** Read one run, or null if the id is unknown. */
|
|
655
|
+
get(workflowId: string): Promise<WorkflowRunSummary | null>;
|
|
656
|
+
/**
|
|
657
|
+
* List runs, newest first. `status: "active"` matches every
|
|
658
|
+
* non-terminal status. `limit` defaults to 100 (max 1000).
|
|
659
|
+
*
|
|
660
|
+
* ```ts
|
|
661
|
+
* // Seller texted STOP: end every cadence for the lead.
|
|
662
|
+
* const runs = await ctx.workflows.list({ key: leadId, status: "active" });
|
|
663
|
+
* for (const run of runs) {
|
|
664
|
+
* await ctx.workflows.cancel(run.id, { reason: "STOP" });
|
|
665
|
+
* }
|
|
666
|
+
* ```
|
|
667
|
+
*/
|
|
668
|
+
list(filter?: {
|
|
669
|
+
name?: string;
|
|
670
|
+
key?: string;
|
|
671
|
+
status?: WorkflowRunStatus | "active";
|
|
672
|
+
limit?: number;
|
|
673
|
+
}): Promise<WorkflowRunSummary[]>;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* The application audit log (`ctx.audit` on mutations and actions).
|
|
678
|
+
* Append-only, stored with the auth audit events.
|
|
679
|
+
*/
|
|
680
|
+
export interface AuditLog {
|
|
681
|
+
/**
|
|
682
|
+
* Record an event. The actor and tenant come from the caller's session.
|
|
683
|
+
* `action` is a short dotted name (`lead.export`, `script.update`) and
|
|
684
|
+
* is stored as `app.<action>`. `meta` values are stored as strings
|
|
685
|
+
* (non-strings as JSON text); keep secrets out of it.
|
|
686
|
+
*
|
|
687
|
+
* Inside a mutation the event is written after the mutation commits and
|
|
688
|
+
* dropped if it rolls back. Rejects with `AUDIT_WRITE_FAILED` when an
|
|
689
|
+
* action's event could not be stored.
|
|
690
|
+
*/
|
|
691
|
+
log(event: {
|
|
692
|
+
action: string;
|
|
693
|
+
entity?: string;
|
|
694
|
+
entityId?: string;
|
|
695
|
+
/** The user the event is about, when not the actor. */
|
|
696
|
+
subject?: string;
|
|
697
|
+
meta?: Record<string, unknown>;
|
|
698
|
+
}): Promise<{ id: string }>;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* `ctx.audit` on actions: {@link AuditLog.log} plus reads. Mutations get
|
|
703
|
+
* only `log`: in a Postgres mutation the transaction holds a connection,
|
|
704
|
+
* and a read would need a second.
|
|
705
|
+
*/
|
|
706
|
+
export interface Audit extends AuditLog {
|
|
707
|
+
/**
|
|
708
|
+
* Read events, newest first. A caller with a tenant reads that tenant's
|
|
709
|
+
* events; a caller without one reads only events it performed. Admin
|
|
710
|
+
* callers may pass `tenant` or read all tenants. `action` matches
|
|
711
|
+
* `lead.export` as `app.lead.export`; framework actions
|
|
712
|
+
* (`entity.update`, `retention.delete`) and auth action names match as
|
|
713
|
+
* given. `limit` defaults to 100 (max 1000). To page,
|
|
714
|
+
* pass `beforeId` set to the last event's `id`. `before` (unix seconds)
|
|
715
|
+
* limits by time.
|
|
716
|
+
*/
|
|
717
|
+
list(filter?: {
|
|
718
|
+
entity?: string;
|
|
719
|
+
entityId?: string;
|
|
720
|
+
actor?: string;
|
|
721
|
+
action?: string;
|
|
722
|
+
tenant?: string;
|
|
723
|
+
before?: number;
|
|
724
|
+
beforeId?: string;
|
|
725
|
+
limit?: number;
|
|
726
|
+
}): Promise<AuditEntry[]>;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export interface AuditEntry {
|
|
730
|
+
id: string;
|
|
731
|
+
/** Unix seconds. */
|
|
732
|
+
createdAt: number;
|
|
733
|
+
/** `app.<action>`, `entity.insert|update|delete`, `retention.delete`, or an auth action. */
|
|
734
|
+
action: string;
|
|
735
|
+
actor: string | null;
|
|
736
|
+
subject: string | null;
|
|
737
|
+
tenant: string | null;
|
|
738
|
+
entity: string | null;
|
|
739
|
+
entityId: string | null;
|
|
740
|
+
ip: string | null;
|
|
741
|
+
success: boolean;
|
|
742
|
+
reason: string | null;
|
|
743
|
+
/** For `entity.*` events, `fields` lists the changed field names. */
|
|
744
|
+
meta: Record<string, string>;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export type WorkflowRunStatus =
|
|
748
|
+
| "pending"
|
|
749
|
+
| "running"
|
|
750
|
+
| "sleeping"
|
|
751
|
+
| "waiting"
|
|
752
|
+
| "completed"
|
|
753
|
+
| "failed"
|
|
754
|
+
| "cancelled";
|
|
755
|
+
|
|
756
|
+
/** A workflow run without its step history. Times are unix seconds. */
|
|
757
|
+
export interface WorkflowRunSummary {
|
|
758
|
+
id: string;
|
|
759
|
+
name: string;
|
|
760
|
+
key: string | null;
|
|
761
|
+
status: WorkflowRunStatus;
|
|
762
|
+
input: unknown;
|
|
763
|
+
output: unknown;
|
|
764
|
+
error: string | null;
|
|
765
|
+
cancelReason: string | null;
|
|
766
|
+
/** Event name the run is waiting for, when `status` is "waiting". */
|
|
767
|
+
waitingFor: string | null;
|
|
768
|
+
/** When the current wait times out, if it has a timeout. */
|
|
769
|
+
waitDeadline: number | null;
|
|
770
|
+
/** When the current sleep ends. */
|
|
771
|
+
wakeAt: number | null;
|
|
772
|
+
/** Events sent but not yet consumed by a `waitForEvent`. */
|
|
773
|
+
bufferedEvents: number;
|
|
774
|
+
createdAt: number | null;
|
|
775
|
+
startedAt: number | null;
|
|
776
|
+
completedAt: number | null;
|
|
619
777
|
}
|
|
620
778
|
|
|
621
779
|
export interface LlmMessage {
|
|
@@ -1029,6 +1187,8 @@ export interface MutationCtx<R extends AuthRequirement = "optional"> {
|
|
|
1029
1187
|
connections: Connections;
|
|
1030
1188
|
/** Durable workflows: start / deliver events — see {@link Workflows}. */
|
|
1031
1189
|
workflows: Workflows;
|
|
1190
|
+
/** Application audit log (write only in mutations) — see {@link AuditLog}. */
|
|
1191
|
+
audit: AuditLog;
|
|
1032
1192
|
/** Signed file-download URLs — see {@link Files}. */
|
|
1033
1193
|
files: Files;
|
|
1034
1194
|
/** Shard tickets, reads, and inputs after the commit — see {@link Shards}. */
|
|
@@ -1063,8 +1223,18 @@ export interface TenantDomainResult {
|
|
|
1063
1223
|
status: string;
|
|
1064
1224
|
/** Whether the hostname + TLS certificate are both active. */
|
|
1065
1225
|
active?: boolean;
|
|
1066
|
-
/**
|
|
1067
|
-
|
|
1226
|
+
/**
|
|
1227
|
+
* True for an apex domain (`acme.com`). An apex cannot be a CNAME, so the
|
|
1228
|
+
* customer adds A/AAAA records pointing at the app instead.
|
|
1229
|
+
*/
|
|
1230
|
+
apex?: boolean;
|
|
1231
|
+
/**
|
|
1232
|
+
* Every DNS record the customer adds, for both kinds of domain: a CNAME
|
|
1233
|
+
* (subdomain) or A/AAAA (apex), plus ownership and certificate records.
|
|
1234
|
+
*/
|
|
1235
|
+
records?: TenantDomainRecord[];
|
|
1236
|
+
/** The CNAME the customer points a subdomain at; null for an apex. */
|
|
1237
|
+
cnameTarget: string | null;
|
|
1068
1238
|
/** TXT record proving domain ownership before DNS cutover (or null). */
|
|
1069
1239
|
ownership: TenantDomainDns | null;
|
|
1070
1240
|
/** TXT records that issue the DV certificate. */
|
|
@@ -1073,6 +1243,14 @@ export interface TenantDomainResult {
|
|
|
1073
1243
|
errors: string[];
|
|
1074
1244
|
}
|
|
1075
1245
|
|
|
1246
|
+
export interface TenantDomainRecord {
|
|
1247
|
+
type: "A" | "AAAA" | "CNAME" | "TXT";
|
|
1248
|
+
name: string;
|
|
1249
|
+
value: string;
|
|
1250
|
+
/** What the record is for, to show the customer. */
|
|
1251
|
+
purpose: string;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1076
1254
|
/**
|
|
1077
1255
|
* Platform domains: attach / register / detach custom domains for your app's
|
|
1078
1256
|
* OWN end-customers, so each tenant can reach your app on their own hostname.
|
|
@@ -1199,6 +1377,8 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
1199
1377
|
connections: Connections;
|
|
1200
1378
|
/** Durable workflows: start / deliver events — see {@link Workflows}. */
|
|
1201
1379
|
workflows: Workflows;
|
|
1380
|
+
/** Application audit log — see {@link Audit}. */
|
|
1381
|
+
audit: Audit;
|
|
1202
1382
|
/** Attach / register custom domains for your app's OWN end-customers
|
|
1203
1383
|
* (platform domains) — see {@link Domains}. Cloud-only. */
|
|
1204
1384
|
domains: Domains;
|
|
@@ -1249,13 +1429,35 @@ export interface ActionCtx<R extends AuthRequirement = "optional"> {
|
|
|
1249
1429
|
* ```
|
|
1250
1430
|
*/
|
|
1251
1431
|
request?: RequestInfo;
|
|
1432
|
+
/**
|
|
1433
|
+
* Build a raw HTTP response. When an action called through
|
|
1434
|
+
* `/api/webhooks/<name>` returns it, the route sends this status,
|
|
1435
|
+
* content type, headers, and body instead of JSON. Other routes return
|
|
1436
|
+
* it as ordinary JSON data. Throws on an invalid status or header.
|
|
1437
|
+
*
|
|
1438
|
+
* ```ts
|
|
1439
|
+
* // Twilio expects TwiML, not JSON.
|
|
1440
|
+
* return ctx.response({ contentType: "text/xml", body: "<Response/>" });
|
|
1441
|
+
* ```
|
|
1442
|
+
*/
|
|
1443
|
+
response(init?: RawResponseInit): RawResponse;
|
|
1252
1444
|
}
|
|
1253
1445
|
|
|
1254
1446
|
/** HTTP request metadata available on an action's ctx when invoked via an
|
|
1255
1447
|
* HTTP route binding. Header names are lowercased. */
|
|
1256
1448
|
export interface RequestInfo {
|
|
1257
1449
|
method: string;
|
|
1450
|
+
/** Request path with the query string, as received. */
|
|
1258
1451
|
path: string;
|
|
1452
|
+
/**
|
|
1453
|
+
* The full URL the client requested: scheme, host, path, and query.
|
|
1454
|
+
* Behind a proxy, the host comes from `X-Forwarded-Host` or `Host`
|
|
1455
|
+
* only when it is trusted (loopback, the `PYLON_PUBLIC_URL` host,
|
|
1456
|
+
* `PYLON_CANONICAL_HOST`, a `PYLON_TRUSTED_HOSTS` entry, or one of the
|
|
1457
|
+
* app's domains); otherwise the `PYLON_PUBLIC_URL` origin is used. Use
|
|
1458
|
+
* it to verify providers that sign the whole URL, such as Twilio.
|
|
1459
|
+
*/
|
|
1460
|
+
url: string;
|
|
1259
1461
|
headers: Record<string, string>;
|
|
1260
1462
|
rawBody: string;
|
|
1261
1463
|
}
|
package/src/workflows.test.ts
CHANGED
|
@@ -177,18 +177,85 @@ describe("executeWorkflowSlice", () => {
|
|
|
177
177
|
expect((res as { error: string }).error).toContain("duplicate step name");
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
test("
|
|
181
|
-
const
|
|
182
|
-
await wf.waitForEvent("
|
|
183
|
-
await wf.waitForEvent("
|
|
180
|
+
test("the same event name can be awaited repeatedly; each wait replays in order", async () => {
|
|
181
|
+
const cadence = workflow("cadence", async (wf) => {
|
|
182
|
+
const first = await wf.waitForEvent<{ n: number }>("reply");
|
|
183
|
+
const second = await wf.waitForEvent<{ n: number }>("reply");
|
|
184
|
+
return { first: first.n, second: second.n };
|
|
184
185
|
});
|
|
186
|
+
const paused = await executeWorkflowSlice(
|
|
187
|
+
cadence,
|
|
188
|
+
req(1, [done("event:reply", { n: 1 })]),
|
|
189
|
+
ctx,
|
|
190
|
+
);
|
|
191
|
+
expect(paused).toEqual({ action: "wait_event", event: "reply" });
|
|
185
192
|
const res = await executeWorkflowSlice(
|
|
186
|
-
|
|
187
|
-
req(
|
|
193
|
+
cadence,
|
|
194
|
+
req(2, [done("event:reply", { n: 1 }), done("event:reply", { n: 2 })]),
|
|
188
195
|
ctx,
|
|
189
196
|
);
|
|
197
|
+
expect(res).toEqual({ action: "complete", output: { first: 1, second: 2 } });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("waitForEvent with a timeout sends the timeout and resolves null on timeout", async () => {
|
|
201
|
+
const lead = workflow("lead", async (wf) => {
|
|
202
|
+
await wf.step("send-sms", () => "sent");
|
|
203
|
+
const reply = await wf.waitForEvent("seller_replied", { timeout: "60s" });
|
|
204
|
+
if (reply === null) {
|
|
205
|
+
await wf.step("call", () => "called");
|
|
206
|
+
return "called";
|
|
207
|
+
}
|
|
208
|
+
return "replied";
|
|
209
|
+
});
|
|
210
|
+
const paused = await executeWorkflowSlice(lead, req(1, [done("send-sms", "sent")]), ctx);
|
|
211
|
+
expect(paused).toEqual({ action: "wait_event", event: "seller_replied", timeout: "60s" });
|
|
212
|
+
|
|
213
|
+
const timedOut = await executeWorkflowSlice(
|
|
214
|
+
lead,
|
|
215
|
+
req(2, [done("send-sms", "sent"), done("timeout:seller_replied", null)]),
|
|
216
|
+
ctx,
|
|
217
|
+
);
|
|
218
|
+
expect(timedOut).toMatchObject({ action: "step_complete", step_name: "call" });
|
|
219
|
+
|
|
220
|
+
const replied = await executeWorkflowSlice(
|
|
221
|
+
lead,
|
|
222
|
+
req(2, [done("send-sms", "sent"), done("event:seller_replied", { body: "hi" })]),
|
|
223
|
+
ctx,
|
|
224
|
+
);
|
|
225
|
+
expect(replied).toEqual({ action: "complete", output: "replied" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("an invalid timeout or sleep duration fails the slice", async () => {
|
|
229
|
+
const badWait = workflow("bad-wait", async (wf) => {
|
|
230
|
+
await wf.waitForEvent("x", { timeout: "soon" });
|
|
231
|
+
});
|
|
232
|
+
const res = await executeWorkflowSlice(badWait, req(0, []), ctx);
|
|
233
|
+
expect(res.action).toBe("fail");
|
|
234
|
+
expect((res as { error: string }).error).toContain("invalid duration");
|
|
235
|
+
|
|
236
|
+
const badSleep = workflow("bad-sleep", async (wf) => {
|
|
237
|
+
await wf.sleep("5 minutes");
|
|
238
|
+
});
|
|
239
|
+
const res2 = await executeWorkflowSlice(badSleep, req(0, []), ctx);
|
|
240
|
+
expect(res2.action).toBe("fail");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("a replayed wait with no record fails loudly", async () => {
|
|
244
|
+
const w = workflow("w", async (wf) => {
|
|
245
|
+
await wf.waitForEvent("x");
|
|
246
|
+
});
|
|
247
|
+
const res = await executeWorkflowSlice(w, req(1, [done("other", 1)]), ctx);
|
|
248
|
+
expect(res.action).toBe("fail");
|
|
249
|
+
expect((res as { error: string }).error).toContain("replay mismatch");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("step names that collide with event records are rejected", async () => {
|
|
253
|
+
const w = workflow("w", async (wf) => {
|
|
254
|
+
await wf.step("event:x", () => 1);
|
|
255
|
+
});
|
|
256
|
+
const res = await executeWorkflowSlice(w, req(0, []), ctx);
|
|
190
257
|
expect(res.action).toBe("fail");
|
|
191
|
-
expect((res as { error: string }).error).toContain("
|
|
258
|
+
expect((res as { error: string }).error).toContain("reserved");
|
|
192
259
|
});
|
|
193
260
|
|
|
194
261
|
test("a stepless workflow completes on its first slice", async () => {
|