@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.50
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/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
- package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
- package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
- package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
- package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
- package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
- package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
- package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
- package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
- package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
- package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
- package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
- package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
- package/.tsbuild/test/observability-signals.test.d.ts +2 -0
- package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
- package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
- package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
- package/.tsbuild/test/snapshot-writer.d.ts +18 -0
- package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
- package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/harness.d.ts +56 -0
- package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
- package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
- package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
- package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
- package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
- package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
- package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
- package/.tsbuild/tsconfig.tsbuildinfo +1 -1
- package/.tsbuild/tsdown.config.d.ts.map +1 -1
- package/.tsbuild/vitest.config.d.ts.map +1 -1
- package/Auth.md +94 -0
- package/CHANGELOG.md +509 -0
- package/README.md +35 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +48 -49
- package/dist/index.mjs.map +1 -1
- package/dist/install-packages.d.mts +2 -1
- package/dist/install-packages.mjs +2 -2
- package/dist/install-packages.mjs.map +1 -1
- package/dist/migrate.d.mts +2 -1
- package/dist/migrate.mjs +4 -3
- package/dist/migrate.mjs.map +1 -1
- package/dist/server-Zz7SqTwO.mjs +3335 -0
- package/dist/server-Zz7SqTwO.mjs.map +1 -0
- package/dist/server.d.mts +154 -30
- package/dist/server.d.mts.map +1 -1
- package/dist/server.mjs +4 -5
- package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
- package/dist/utils-uaBlzyjS.mjs.map +1 -0
- package/dist/utils.d.mts +4 -5
- package/dist/utils.d.mts.map +1 -1
- package/dist/utils.mjs +3 -3
- package/package.json +22 -18
- package/test/attachment-reference-read-model.test.ts +6 -1
- package/test/attachments/auth.test.ts +85 -0
- package/test/attachments/byte-grants.test.ts +290 -0
- package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
- package/test/attachments/download-target.test.ts +201 -32
- package/test/attachments/index.test.ts +34 -3
- package/test/attachments/reserve-metadata-parity.test.ts +96 -0
- package/test/attachments/routes-integration.test.ts +60 -4
- package/test/attachments/routes.test.ts +166 -23
- package/test/attachments/service-config.test.ts +13 -3
- package/test/attachments/url-signer.test.ts +105 -0
- package/test/boot-create-signature-policy.test.ts +108 -0
- package/test/boot-mcp-enabled.test.ts +121 -0
- package/test/boot-privacy.test.ts +243 -0
- package/test/boot-unsupported-stored-documents.test.ts +213 -0
- package/test/boot-without-renown.test.ts +81 -0
- package/test/boot-without-require-signatures.test.ts +74 -0
- package/test/boot-worker-trust-policy.test.ts +90 -0
- package/test/builder-defaults.test.ts +80 -0
- package/test/create-signature-policy.test.ts +69 -0
- package/test/durable-node-fs-pg16.test.ts +80 -0
- package/test/fatal-shutdown.test.ts +165 -0
- package/test/mcp-flag.test.ts +56 -0
- package/test/observability-signals.test.ts +131 -0
- package/test/pglite-dialect.test.ts +159 -2
- package/test/pglite-preflight.test.ts +313 -0
- package/test/pglite-version.test.ts +9 -1
- package/test/renown-trust-policy.test.ts +315 -0
- package/test/snapshot-writer.ts +94 -0
- package/test/unsupported-stored-documents.test.ts +72 -0
- package/test/worker-pool.test.ts +27 -1
- package/test/workflow/harness.ts +289 -0
- package/test/workflow/oauth.test.ts +214 -0
- package/test/workflow/resolvers.test.ts +322 -0
- package/test/workflow/webhook-dedupe.test.ts +295 -0
- package/test/workflow/webhook-signed.test.ts +509 -0
- package/test/workflow/webhook-trigger-test.test.ts +82 -0
- package/test/workflow/webhook-unsigned.test.ts +345 -0
- package/test/workflow-reactor-access.test.ts +384 -0
- package/test/workflow-reactor-enforcement.test.ts +805 -0
- package/test/workflow-read-gate.test.ts +210 -0
- package/test/workflow-runtime.test.ts +572 -0
- package/tsconfig.json +9 -3
- package/tsdown.config.ts +3 -1
- package/dist/server-CiKkVE6g.mjs +0 -1555
- package/dist/server-CiKkVE6g.mjs.map +0 -1
- package/dist/utils-Baw7rThP.mjs.map +0 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Shared harness: the suites go over a real socket through the reactor's own
|
|
2
|
+
// adapter, route service and webhook service, which the unit tests cannot.
|
|
3
|
+
import {
|
|
4
|
+
createHttpAdapter,
|
|
5
|
+
getDbClient,
|
|
6
|
+
HttpRouteService,
|
|
7
|
+
MemoryWebhookStore,
|
|
8
|
+
WebhookService,
|
|
9
|
+
} from "@powerhousedao/reactor-api";
|
|
10
|
+
import {
|
|
11
|
+
createWorkflowRuntime,
|
|
12
|
+
type WorkflowRuntimeService,
|
|
13
|
+
} from "@powerhousedao/reactor-workflow";
|
|
14
|
+
import {
|
|
15
|
+
createRelationalDb,
|
|
16
|
+
type IRelationalDb,
|
|
17
|
+
} from "@powerhousedao/shared/processors";
|
|
18
|
+
import type { OperationWithContext } from "document-model";
|
|
19
|
+
import type { Kysely } from "kysely";
|
|
20
|
+
import { vi } from "vitest";
|
|
21
|
+
import {
|
|
22
|
+
CORE_PIECE_NAME,
|
|
23
|
+
CORE_PIECE_VERSION,
|
|
24
|
+
} from "@powerhousedao/reactor-workflow";
|
|
25
|
+
|
|
26
|
+
export const WORKFLOW_TYPE = "powerhouse/workflow";
|
|
27
|
+
export const PACKAGE_NAME = "@powerhousedao/workflow";
|
|
28
|
+
/** A ref the harness's secret store resolves; anything else rejects. */
|
|
29
|
+
export const SECRET_REF = "secret://v1:00112233445566778899aabbccddeeff";
|
|
30
|
+
export const SECRET = "s3cret";
|
|
31
|
+
// Minting an endpoint is a read of the workflow, so the harness asks as one.
|
|
32
|
+
const CALLER = { headers: {}, db: {}, user: { address: "0xabc" } } as never;
|
|
33
|
+
|
|
34
|
+
export interface FiredRun {
|
|
35
|
+
workflowId: string;
|
|
36
|
+
payload: unknown;
|
|
37
|
+
kind: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TriggerFields {
|
|
41
|
+
pieceName: string;
|
|
42
|
+
pieceVersion: string;
|
|
43
|
+
triggerName: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The core piece's webhook trigger, pinned to the installed core piece.
|
|
47
|
+
export const WEBHOOK_TRIGGER: TriggerFields = {
|
|
48
|
+
pieceName: CORE_PIECE_NAME,
|
|
49
|
+
pieceVersion: CORE_PIECE_VERSION,
|
|
50
|
+
triggerName: "webhook",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface WebhookHost {
|
|
54
|
+
/** Origin the endpoint is served from, e.g. `http://127.0.0.1:53124`. */
|
|
55
|
+
readonly url: string;
|
|
56
|
+
/** Runs the trigger started, in order. Cleared by `arm`. */
|
|
57
|
+
readonly fired: FiredRun[];
|
|
58
|
+
readonly service: WorkflowRuntimeService;
|
|
59
|
+
/** Publishes an ENABLED workflow with `config`; returns its endpoint. */
|
|
60
|
+
arm(
|
|
61
|
+
config: Record<string, unknown>,
|
|
62
|
+
options?: { trigger?: TriggerFields; workflowId?: string },
|
|
63
|
+
): Promise<{ token: string; url: string }>;
|
|
64
|
+
/** Publishes the same workflow as DISABLED, keeping its endpoint row. */
|
|
65
|
+
disarm(workflowId?: string): Promise<void>;
|
|
66
|
+
/** Deletes the workflow document, as the reactor reports it. */
|
|
67
|
+
remove(workflowId?: string): Promise<void>;
|
|
68
|
+
/** Whether the reactor's webhook store still holds this token. */
|
|
69
|
+
hasToken(token: string): Promise<boolean>;
|
|
70
|
+
/** The policy the service hands the reactor for one endpoint. */
|
|
71
|
+
policyFor(workflowId?: string): Promise<unknown>;
|
|
72
|
+
deliver(
|
|
73
|
+
token: string,
|
|
74
|
+
init?: {
|
|
75
|
+
method?: string;
|
|
76
|
+
headers?: Record<string, string>;
|
|
77
|
+
body?: string;
|
|
78
|
+
query?: Record<string, string>;
|
|
79
|
+
},
|
|
80
|
+
): Promise<Response>;
|
|
81
|
+
stop(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const DEFAULT_WORKFLOW = "wf-integration";
|
|
85
|
+
|
|
86
|
+
// Module-scoped: the service ignores any ordinal at or below the highest it
|
|
87
|
+
// has seen, so a per-host counter would make a file's second host see nothing.
|
|
88
|
+
let ordinal = 0;
|
|
89
|
+
|
|
90
|
+
export function workflowOperation(
|
|
91
|
+
state: Record<string, unknown>,
|
|
92
|
+
workflowId = DEFAULT_WORKFLOW,
|
|
93
|
+
): OperationWithContext {
|
|
94
|
+
ordinal += 1;
|
|
95
|
+
return {
|
|
96
|
+
operation: {
|
|
97
|
+
index: ordinal,
|
|
98
|
+
timestampUtcMs: `${ordinal}`,
|
|
99
|
+
action: { type: "SET_WORKFLOW_NAME", input: {} },
|
|
100
|
+
resultingState: JSON.stringify(state),
|
|
101
|
+
},
|
|
102
|
+
context: {
|
|
103
|
+
documentId: workflowId,
|
|
104
|
+
documentType: WORKFLOW_TYPE,
|
|
105
|
+
scope: "global",
|
|
106
|
+
branch: "main",
|
|
107
|
+
ordinal,
|
|
108
|
+
},
|
|
109
|
+
} as unknown as OperationWithContext;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `publicUrl` is known only after `listen`: a family registered against the
|
|
113
|
+
// wrong origin advertises URLs no caller can reach.
|
|
114
|
+
export async function startWebhookHost(
|
|
115
|
+
options: {
|
|
116
|
+
fire?: (run: FiredRun) => unknown;
|
|
117
|
+
// Reads and writes of the workflow documents; none by default.
|
|
118
|
+
reactorClient?: unknown;
|
|
119
|
+
} = {},
|
|
120
|
+
): Promise<WebhookHost> {
|
|
121
|
+
// The public factory, not the adapter class: the class is internal, so
|
|
122
|
+
// constructing it would test a path no consumer can take.
|
|
123
|
+
const { adapter } = await createHttpAdapter("express");
|
|
124
|
+
adapter.setupMiddleware({});
|
|
125
|
+
const server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
126
|
+
const { port } = server.address() as { port: number };
|
|
127
|
+
const url = `http://127.0.0.1:${port}`;
|
|
128
|
+
|
|
129
|
+
const webhookStore = new MemoryWebhookStore();
|
|
130
|
+
const webhooks = new WebhookService({ store: webhookStore });
|
|
131
|
+
const routes = new HttpRouteService({
|
|
132
|
+
httpAdapter: adapter,
|
|
133
|
+
webhooks,
|
|
134
|
+
publicUrl: url,
|
|
135
|
+
});
|
|
136
|
+
// Core serves the endpoint family from its own host scope, exactly as the
|
|
137
|
+
// reactor wires it: nothing here touches the adapter directly.
|
|
138
|
+
webhooks.attach(routes.hostScope("@powerhousedao/reactor-api", "/webhooks"));
|
|
139
|
+
|
|
140
|
+
const { db } = getDbClient();
|
|
141
|
+
const fired: FiredRun[] = [];
|
|
142
|
+
|
|
143
|
+
// Every host surface the runtime asks for, and nothing it does not: no run
|
|
144
|
+
// here reaches the reactor, and the secret store answers one fixed ref.
|
|
145
|
+
const service = createWorkflowRuntime({
|
|
146
|
+
relationalDb: createRelationalDb(
|
|
147
|
+
db as unknown as Kysely<unknown>,
|
|
148
|
+
) as IRelationalDb,
|
|
149
|
+
reactorClient: options.reactorClient ?? {
|
|
150
|
+
get: () => Promise.reject(new Error("not used")),
|
|
151
|
+
find: () => Promise.resolve({ results: [] }),
|
|
152
|
+
},
|
|
153
|
+
assertCanRead: () => Promise.resolve(undefined),
|
|
154
|
+
assertCanWrite: () => Promise.resolve(undefined),
|
|
155
|
+
webhooks: routes.scopeFor(PACKAGE_NAME).webhooks,
|
|
156
|
+
secrets: {
|
|
157
|
+
get: (ref: string) =>
|
|
158
|
+
ref === SECRET_REF
|
|
159
|
+
? Promise.resolve(SECRET)
|
|
160
|
+
: Promise.reject(new Error(`No secret found for ref "${ref}"`)),
|
|
161
|
+
},
|
|
162
|
+
} as never);
|
|
163
|
+
vi.spyOn(service, "fire").mockImplementation(
|
|
164
|
+
(workflowId: string, payload?: unknown, kind = "manual") => {
|
|
165
|
+
const run = { workflowId, payload, kind };
|
|
166
|
+
fired.push(run);
|
|
167
|
+
const outcome = options.fire?.(run);
|
|
168
|
+
return Promise.resolve(
|
|
169
|
+
(outcome ?? {
|
|
170
|
+
runId: `run-${fired.length}`,
|
|
171
|
+
status: "SUCCEEDED",
|
|
172
|
+
steps: [],
|
|
173
|
+
}) as never,
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await service.registerWebhookEndpoint();
|
|
179
|
+
|
|
180
|
+
const publish = async (
|
|
181
|
+
config: Record<string, unknown>,
|
|
182
|
+
status: string,
|
|
183
|
+
trigger: TriggerFields,
|
|
184
|
+
workflowId: string,
|
|
185
|
+
) => {
|
|
186
|
+
await service.onOperations([
|
|
187
|
+
workflowOperation(
|
|
188
|
+
{
|
|
189
|
+
name: "Integration",
|
|
190
|
+
status,
|
|
191
|
+
version: ordinal + 1,
|
|
192
|
+
trigger: { id: "t1", ...trigger, config },
|
|
193
|
+
steps: [],
|
|
194
|
+
edges: [],
|
|
195
|
+
variables: [],
|
|
196
|
+
},
|
|
197
|
+
workflowId,
|
|
198
|
+
),
|
|
199
|
+
]);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
url,
|
|
204
|
+
fired,
|
|
205
|
+
service,
|
|
206
|
+
async arm(config, opts = {}) {
|
|
207
|
+
const workflowId = opts.workflowId ?? DEFAULT_WORKFLOW;
|
|
208
|
+
// The runtime requires a scheme; an unsigned suite names "none".
|
|
209
|
+
await publish(
|
|
210
|
+
{ scheme: "none", ...config },
|
|
211
|
+
"ENABLED",
|
|
212
|
+
opts.trigger ?? WEBHOOK_TRIGGER,
|
|
213
|
+
workflowId,
|
|
214
|
+
);
|
|
215
|
+
const endpoint = await service.webhookEndpoint(workflowId, CALLER);
|
|
216
|
+
if (!endpoint) throw new Error(`No endpoint minted for ${workflowId}`);
|
|
217
|
+
fired.length = 0;
|
|
218
|
+
return {
|
|
219
|
+
url: endpoint.url,
|
|
220
|
+
token: endpoint.url.slice(endpoint.url.lastIndexOf("/") + 1),
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
async disarm(workflowId = DEFAULT_WORKFLOW) {
|
|
224
|
+
await publish({}, "DISABLED", WEBHOOK_TRIGGER, workflowId);
|
|
225
|
+
},
|
|
226
|
+
async remove(workflowId = DEFAULT_WORKFLOW) {
|
|
227
|
+
ordinal += 1;
|
|
228
|
+
await service.onOperations([
|
|
229
|
+
{
|
|
230
|
+
operation: {
|
|
231
|
+
index: ordinal,
|
|
232
|
+
timestampUtcMs: `${ordinal}`,
|
|
233
|
+
action: {
|
|
234
|
+
type: "DELETE_DOCUMENT",
|
|
235
|
+
input: { documentId: workflowId },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
context: {
|
|
239
|
+
documentId: workflowId,
|
|
240
|
+
documentType: WORKFLOW_TYPE,
|
|
241
|
+
scope: "document",
|
|
242
|
+
branch: "main",
|
|
243
|
+
ordinal,
|
|
244
|
+
},
|
|
245
|
+
} as unknown as OperationWithContext,
|
|
246
|
+
]);
|
|
247
|
+
},
|
|
248
|
+
async hasToken(token) {
|
|
249
|
+
return (await webhookStore.find(token)) !== undefined;
|
|
250
|
+
},
|
|
251
|
+
policyFor(workflowId = DEFAULT_WORKFLOW) {
|
|
252
|
+
return service.webhookPolicy(workflowId);
|
|
253
|
+
},
|
|
254
|
+
deliver(token, init = {}) {
|
|
255
|
+
const query = init.query
|
|
256
|
+
? `?${new URLSearchParams(init.query).toString()}`
|
|
257
|
+
: "";
|
|
258
|
+
return fetch(`${url}/webhooks/${token}${query}`, {
|
|
259
|
+
method: init.method ?? "POST",
|
|
260
|
+
headers: init.headers,
|
|
261
|
+
body: init.body,
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
stop() {
|
|
265
|
+
return new Promise<void>((resolve, reject) =>
|
|
266
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
267
|
+
);
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A delivery is answered before its run starts, so asserting on `fired`
|
|
273
|
+
// straight after a 202 is a race that passes for the wrong reason.
|
|
274
|
+
export async function waitForRuns(
|
|
275
|
+
host: WebhookHost,
|
|
276
|
+
count: number,
|
|
277
|
+
timeoutMs = 2000,
|
|
278
|
+
): Promise<FiredRun[]> {
|
|
279
|
+
const deadline = Date.now() + timeoutMs;
|
|
280
|
+
while (host.fired.length < count && Date.now() < deadline) {
|
|
281
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
282
|
+
}
|
|
283
|
+
if (host.fired.length < count) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Expected ${count} run(s), saw ${host.fired.length} within ${timeoutMs}ms`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return host.fired;
|
|
289
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The OAuth2 callback over a real socket through the reactor's route service,
|
|
2
|
+
// and what startOAuth accepts as a redirect and a return URL.
|
|
3
|
+
import {
|
|
4
|
+
createHttpAdapter,
|
|
5
|
+
HttpRouteService,
|
|
6
|
+
type Context,
|
|
7
|
+
type IAuthorizationService,
|
|
8
|
+
} from "@powerhousedao/reactor-api";
|
|
9
|
+
import type { WorkflowRuntimeService } from "@powerhousedao/reactor-workflow";
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
callbackUrlOf,
|
|
13
|
+
registerOAuthCallback,
|
|
14
|
+
} from "../../src/workflow/oauth-callback.js";
|
|
15
|
+
import { getResolvers } from "../../src/workflow/resolvers.js";
|
|
16
|
+
|
|
17
|
+
const PACKAGE_NAME = "@powerhousedao/workflow";
|
|
18
|
+
|
|
19
|
+
type Resolver = (
|
|
20
|
+
parent: unknown,
|
|
21
|
+
args: unknown,
|
|
22
|
+
ctx: Context,
|
|
23
|
+
) => Promise<unknown>;
|
|
24
|
+
|
|
25
|
+
describe("the OAuth2 callback route", () => {
|
|
26
|
+
let url: string;
|
|
27
|
+
let callbackUrl: string;
|
|
28
|
+
const completeOAuth = vi.fn();
|
|
29
|
+
let close: () => Promise<void>;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
const { adapter } = await createHttpAdapter("express");
|
|
33
|
+
adapter.setupMiddleware({});
|
|
34
|
+
const server = await adapter.listen(0, undefined, "127.0.0.1");
|
|
35
|
+
const { port } = server.address() as { port: number };
|
|
36
|
+
url = `http://127.0.0.1:${port}`;
|
|
37
|
+
const routes = new HttpRouteService({
|
|
38
|
+
httpAdapter: adapter,
|
|
39
|
+
publicUrl: url,
|
|
40
|
+
});
|
|
41
|
+
const scope = routes.scopeFor(PACKAGE_NAME);
|
|
42
|
+
registerOAuthCallback(scope, { completeOAuth } as never);
|
|
43
|
+
callbackUrl = callbackUrlOf(scope);
|
|
44
|
+
close = () => new Promise((done) => server.close(() => done()));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => close());
|
|
48
|
+
|
|
49
|
+
it("lives under the workflow package's namespace", () => {
|
|
50
|
+
expect(callbackUrl).toBe(
|
|
51
|
+
`${url}/api/@powerhousedao/workflow/oauth/callback`,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("hands the provider's answer to the runtime and says it is done", async () => {
|
|
56
|
+
completeOAuth.mockResolvedValueOnce({
|
|
57
|
+
ok: true,
|
|
58
|
+
detail: null,
|
|
59
|
+
returnUrl: null,
|
|
60
|
+
});
|
|
61
|
+
const response = await fetch(`${callbackUrl}?state=s1&code=c1`);
|
|
62
|
+
expect(response.status).toBe(200);
|
|
63
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
64
|
+
expect(await response.text()).toContain("<h1>Connected</h1>");
|
|
65
|
+
expect(completeOAuth).toHaveBeenLastCalledWith({
|
|
66
|
+
state: "s1",
|
|
67
|
+
code: "c1",
|
|
68
|
+
error: undefined,
|
|
69
|
+
errorDescription: undefined,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("shows a failure without letting it write markup", async () => {
|
|
74
|
+
completeOAuth.mockResolvedValueOnce({
|
|
75
|
+
ok: false,
|
|
76
|
+
detail: "<script>alert(1)</script>",
|
|
77
|
+
returnUrl: null,
|
|
78
|
+
});
|
|
79
|
+
const response = await fetch(`${callbackUrl}?state=s2&error=access_denied`);
|
|
80
|
+
expect(response.status).toBe(400);
|
|
81
|
+
const html = await response.text();
|
|
82
|
+
expect(html).toContain("<script>alert(1)</script>");
|
|
83
|
+
expect(html).not.toContain("window.close");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("sends a full-page sign-in back where it started", async () => {
|
|
87
|
+
completeOAuth.mockResolvedValueOnce({
|
|
88
|
+
ok: true,
|
|
89
|
+
detail: null,
|
|
90
|
+
returnUrl: "https://connect.example/d/drive?tab=x",
|
|
91
|
+
});
|
|
92
|
+
const response = await fetch(`${callbackUrl}?state=s3&code=c3`, {
|
|
93
|
+
redirect: "manual",
|
|
94
|
+
});
|
|
95
|
+
expect(response.status).toBe(303);
|
|
96
|
+
expect(response.headers.get("location")).toBe(
|
|
97
|
+
"https://connect.example/d/drive?tab=x&ph_oauth=s3",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("refuses a request with no state", async () => {
|
|
102
|
+
completeOAuth.mockClear();
|
|
103
|
+
const response = await fetch(`${callbackUrl}?code=c4`);
|
|
104
|
+
expect(response.status).toBe(400);
|
|
105
|
+
expect(completeOAuth).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("startOAuth", () => {
|
|
110
|
+
const ORIGIN = "https://connect.example";
|
|
111
|
+
const ctx = (origin?: string) =>
|
|
112
|
+
({
|
|
113
|
+
headers: origin ? { origin } : {},
|
|
114
|
+
db: {},
|
|
115
|
+
user: { address: "0xadmin" },
|
|
116
|
+
}) as unknown as Context;
|
|
117
|
+
|
|
118
|
+
function build(callbackUrl: string | undefined, isAdmin = true) {
|
|
119
|
+
const runtime = {
|
|
120
|
+
startOAuth: vi.fn(() => Promise.resolve({})),
|
|
121
|
+
};
|
|
122
|
+
const resolvers = getResolvers(
|
|
123
|
+
runtime as unknown as WorkflowRuntimeService,
|
|
124
|
+
{ isSupremeAdmin: () => isAdmin } as unknown as IAuthorizationService,
|
|
125
|
+
callbackUrl ? { callbackUrl } : undefined,
|
|
126
|
+
) as Record<string, Record<string, Resolver>>;
|
|
127
|
+
return {
|
|
128
|
+
runtime,
|
|
129
|
+
start: resolvers.WorkflowRuntimeMutations.startOAuth,
|
|
130
|
+
redirectUri: resolvers.WorkflowRuntimeQueries.oauthRedirectUri,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PATH = "/api/@powerhousedao/workflow/oauth/callback";
|
|
135
|
+
|
|
136
|
+
it("is an administrator's, like minting a secret", () => {
|
|
137
|
+
const { start } = build(`https://sb.example${PATH}`, false);
|
|
138
|
+
expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
|
|
139
|
+
"Admin access required",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("uses the host's own redirect when it knows its origin", async () => {
|
|
144
|
+
const { runtime, start } = build(`https://sb.example${PATH}`);
|
|
145
|
+
await start(
|
|
146
|
+
{},
|
|
147
|
+
{ connectionId: "c1", redirectUri: `https://evil.example${PATH}` },
|
|
148
|
+
ctx(),
|
|
149
|
+
);
|
|
150
|
+
expect(runtime.startOAuth).toHaveBeenCalledWith("c1", expect.anything(), {
|
|
151
|
+
redirectUri: `https://sb.example${PATH}`,
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("takes the caller's redirect only when it names this host's path", async () => {
|
|
156
|
+
const { runtime, start, redirectUri } = build(PATH);
|
|
157
|
+
expect(redirectUri({}, {}, ctx())).toBe(PATH);
|
|
158
|
+
expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
|
|
159
|
+
"pass redirectUri",
|
|
160
|
+
);
|
|
161
|
+
expect(() =>
|
|
162
|
+
start(
|
|
163
|
+
{},
|
|
164
|
+
{ connectionId: "c1", redirectUri: "http://localhost:4001/elsewhere" },
|
|
165
|
+
ctx(),
|
|
166
|
+
),
|
|
167
|
+
).toThrow(`redirectUri must be this host's ${PATH}`);
|
|
168
|
+
await start(
|
|
169
|
+
{},
|
|
170
|
+
{ connectionId: "c1", redirectUri: `http://localhost:4001${PATH}` },
|
|
171
|
+
ctx(),
|
|
172
|
+
);
|
|
173
|
+
expect(runtime.startOAuth).toHaveBeenLastCalledWith(
|
|
174
|
+
"c1",
|
|
175
|
+
expect.anything(),
|
|
176
|
+
{ redirectUri: `http://localhost:4001${PATH}` },
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns a full-page sign-in only to the page that asked", async () => {
|
|
181
|
+
const { runtime, start } = build(`https://sb.example${PATH}`);
|
|
182
|
+
expect(() =>
|
|
183
|
+
start(
|
|
184
|
+
{},
|
|
185
|
+
{ connectionId: "c1", returnUrl: "https://evil.example/" },
|
|
186
|
+
ctx(ORIGIN),
|
|
187
|
+
),
|
|
188
|
+
).toThrow("returnUrl must be on the requesting page's origin");
|
|
189
|
+
expect(() =>
|
|
190
|
+
start({}, { connectionId: "c1", returnUrl: `${ORIGIN}/d` }, ctx()),
|
|
191
|
+
).toThrow("returnUrl must be on the requesting page's origin");
|
|
192
|
+
await start(
|
|
193
|
+
{},
|
|
194
|
+
{ connectionId: "c1", returnUrl: `${ORIGIN}/d?x=1` },
|
|
195
|
+
ctx(ORIGIN),
|
|
196
|
+
);
|
|
197
|
+
expect(runtime.startOAuth).toHaveBeenLastCalledWith(
|
|
198
|
+
"c1",
|
|
199
|
+
expect.anything(),
|
|
200
|
+
{
|
|
201
|
+
redirectUri: `https://sb.example${PATH}`,
|
|
202
|
+
returnUrl: `${ORIGIN}/d?x=1`,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("has no redirect to offer on a host without HTTP routes", () => {
|
|
208
|
+
const { redirectUri, start } = build(undefined);
|
|
209
|
+
expect(redirectUri({}, {}, ctx())).toBeNull();
|
|
210
|
+
expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
|
|
211
|
+
"This host serves no OAuth2 callback",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|