diff --git a/.changeset/scope-do-workflow-explorer-storage-scope.md b/.changeset/scope-do-workflow-explorer-storage-scope.md new file mode 100644 index 00000000000..58bf63b1030 --- /dev/null +++ b/.changeset/scope-do-workflow-explorer-storage-scope.md @@ -0,0 +1,7 @@ +--- +"miniflare": patch +--- + +Scope Durable Object and Workflow local explorer peers by storageScope + +Restricts Durable Object and Workflow peer discovery and owner resolution to Miniflare peers sharing the same storageScope when Shared Storage is enabled. This ensures consistency with KV, D1, and R2 local explorer behaviors and prevents cross-project access to local development state across instances with different persistence roots. diff --git a/packages/miniflare/src/workers/local-explorer/resources/do.ts b/packages/miniflare/src/workers/local-explorer/resources/do.ts index 008963d6bff..9e15c3efdab 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/do.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/do.ts @@ -79,7 +79,9 @@ async function findDONamespaceOwner( c: AppContext, namespaceId: string ): Promise { - const peerUrls = await getPeerUrlsIfAggregating(c); + const peerUrls = await getPeerUrlsIfAggregating(c, { + sharedStorageOnly: true, + }); if (peerUrls.length === 0) { return null; } @@ -118,13 +120,14 @@ async function findDONamespaceOwner( */ export async function listDONamespaces(c: AppContext) { const localNamespaces = getLocalDONamespaces(c.env); - // note that we don't have duplication issues here like - // we do for listD1Namespaces etc. because DOs are tied - // to scripts and external DOs have already been filtered out const allNamespaces = await aggregateListResults( c, localNamespaces, - "/workers/durable_objects/namespaces" + "/workers/durable_objects/namespaces", + { + getKey: (namespace) => namespace.id, + sharedStorageOnly: true, + } ); return c.json({ diff --git a/packages/miniflare/src/workers/local-explorer/resources/workflows.ts b/packages/miniflare/src/workers/local-explorer/resources/workflows.ts index 0817f8ebc23..84d0913a29c 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/workflows.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/workflows.ts @@ -211,7 +211,9 @@ async function findWorkflowOwner( return cached.url; } - const peerUrls = await getPeerUrlsIfAggregating(c); + const peerUrls = await getPeerUrlsIfAggregating(c, { + sharedStorageOnly: true, + }); if (peerUrls.length === 0) { return null; } @@ -247,7 +249,8 @@ export async function listWorkflows(c: AppContext): Promise { const aggregatedWorkflows = await aggregateListResults( c, localWorkflows, - "/workflows" + "/workflows", + { getKey: (wf) => wf.name, sharedStorageOnly: true } ); // Deduplicate by name — first occurrence wins (local takes priority) diff --git a/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts b/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts index 9cd8a370aee..bb4f09e1d29 100644 --- a/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts @@ -54,10 +54,14 @@ describe("Cross-process aggregation", () => { name: "worker-a", compatibilityDate: "2025-01-01", manifest: singleModuleManifest(` + import { WorkflowEntrypoint } from "cloudflare:workers"; export class MyDO { constructor(state) { this.state = state; } async fetch() { return new Response("DO A"); } } + export class WorkflowA extends WorkflowEntrypoint { + async run() { return "Workflow A"; } + } export default { fetch() { return new Response("Worker A"); } } `), env: { @@ -70,6 +74,12 @@ describe("Cross-process aggregation", () => { exportName: "MyDO", }, BUCKET_A: { type: "r2", name: "bucket-a" }, + WF_A: { + type: "workflow", + name: "workflow-a", + worker: "worker-a", + exportName: "WorkflowA", + }, }, exports: { MyDO: { type: "durable-object", storage: "legacy-kv" }, @@ -91,10 +101,14 @@ describe("Cross-process aggregation", () => { name: "worker-b", compatibilityDate: "2025-01-01", manifest: singleModuleManifest(` + import { WorkflowEntrypoint } from "cloudflare:workers"; export class OtherDO { constructor(state) { this.state = state; } async fetch() { return new Response("DO B"); } } + export class WorkflowB extends WorkflowEntrypoint { + async run() { return "Workflow B"; } + } export default { fetch() { return new Response("Worker B"); } } `), env: { @@ -106,6 +120,12 @@ describe("Cross-process aggregation", () => { exportName: "OtherDO", }, BUCKET_B: { type: "r2", name: "bucket-b" }, + WF_B: { + type: "workflow", + name: "workflow-b", + worker: "worker-b", + exportName: "WorkflowB", + }, }, exports: { OtherDO: { type: "durable-object", storage: "legacy-kv" }, @@ -325,7 +345,9 @@ describe("Cross-process aggregation", () => { }); describe("DO namespace aggregation", () => { - test("lists DO namespaces from both instances", async ({ expect }) => { + test("only lists local DO namespaces without shared storage", async ({ + expect, + }) => { const response = await instanceA.dispatchFetch( `${BASE_URL}/workers/durable_objects/namespaces` ); @@ -340,20 +362,30 @@ describe("Cross-process aggregation", () => { "id": "worker-a-MyDO", "name": "worker-a_MyDO", }, - { - "id": "worker-b-OtherDO", - "name": "worker-b_OtherDO", - }, ] `); expect(normalized.result_info).toMatchInlineSnapshot(` { - "count": 2, + "count": 1, } `); }); }); + describe("workflow aggregation", () => { + test("only lists local workflows without shared storage", async ({ + expect, + }) => { + const response = await instanceA.dispatchFetch(`${BASE_URL}/workflows`); + const data = (await response.json()) as { + result?: Array<{ name: string }>; + result_info?: { count?: number }; + }; + expect(data.result).toMatchObject([{ name: "workflow-a" }]); + expect(data.result_info?.count).toBe(1); + }); + }); + describe("r2 bucket aggregation", () => { test("only lists local R2 buckets without shared storage", async ({ expect, @@ -416,11 +448,33 @@ describe("Multi-worker peer deduplication", () => { type: "worker", name: "worker-a", compatibilityDate: "2025-01-01", - manifest: singleModuleManifest( - `export default { fetch() { return new Response("Worker A"); } }` - ), + manifest: singleModuleManifest(` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class MyDO { + constructor(state) { this.state = state; } + async fetch() { return new Response("DO A"); } + } + export class MyWorkflowA extends WorkflowEntrypoint { + async run() { return "Workflow A"; } + } + export default { fetch() { return new Response("Worker A"); } } + `), env: { KV_A: { type: "kv", id: "kv-a" }, + DO_A: { + type: "durable-object", + worker: "worker-a", + exportName: "MyDO", + }, + WF_A: { + type: "workflow", + name: "workflow-a", + worker: "worker-a", + exportName: "MyWorkflowA", + }, + }, + exports: { + MyDO: { type: "durable-object", storage: "legacy-kv" }, }, }, }, @@ -444,11 +498,33 @@ describe("Multi-worker peer deduplication", () => { type: "worker", name: "worker-b1", compatibilityDate: "2025-01-01", - manifest: singleModuleManifest( - `export default { fetch() { return new Response("Worker B1"); } }` - ), + manifest: singleModuleManifest(` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class OtherDO { + constructor(state) { this.state = state; } + async fetch() { return new Response("DO B1"); } + } + export class MyWorkflowB extends WorkflowEntrypoint { + async run() { return "Workflow B"; } + } + export default { fetch() { return new Response("Worker B1"); } } + `), env: { KV_B1: { type: "kv", id: "kv-b1" }, + DO_B: { + type: "durable-object", + worker: "worker-b1", + exportName: "OtherDO", + }, + WF_B: { + type: "workflow", + name: "workflow-b", + worker: "worker-b1", + exportName: "MyWorkflowB", + }, + }, + exports: { + OtherDO: { type: "durable-object", storage: "legacy-kv" }, }, }, }, @@ -466,6 +542,23 @@ describe("Multi-worker peer deduplication", () => { }, }, }, + { + config: { + type: "worker", + name: "worker-shared", + compatibilityDate: "2025-01-01", + manifest: singleModuleManifest(` + export class SharedDO { + constructor(state) { this.state = state; } + async fetch() { return new Response("Shared DO"); } + } + export default { fetch() { return new Response("Shared Worker"); } } + `), + exports: { + SharedDO: { type: "durable-object", storage: "legacy-kv" }, + }, + }, + }, ], }); await instanceB.ready; @@ -491,6 +584,29 @@ describe("Multi-worker peer deduplication", () => { ), env: { KV_B1: { type: "kv", id: "kv-b1" }, + WF_B: { + type: "workflow", + name: "workflow-b", + worker: "worker-b1", + exportName: "MyWorkflowB", + }, + }, + }, + }, + { + config: { + type: "worker", + name: "worker-shared", + compatibilityDate: "2025-01-01", + manifest: singleModuleManifest(` + export class SharedDO { + constructor(state) { this.state = state; } + async fetch() { return new Response("Shared DO"); } + } + export default { fetch() { return new Response("Shared Worker"); } } + `), + exports: { + SharedDO: { type: "durable-object", storage: "legacy-kv" }, }, }, }, @@ -512,11 +628,33 @@ describe("Multi-worker peer deduplication", () => { type: "worker", name: "worker-c", compatibilityDate: "2025-01-01", - manifest: singleModuleManifest( - `export default { fetch() { return new Response("Worker C"); } }` - ), + manifest: singleModuleManifest(` + import { WorkflowEntrypoint } from "cloudflare:workers"; + export class ScopedDO { + constructor(state) { this.state = state; } + async fetch() { return new Response("DO C"); } + } + export class MyWorkflowC extends WorkflowEntrypoint { + async run() { return "Workflow C"; } + } + export default { fetch() { return new Response("Worker C"); } } + `), env: { KV_C: { type: "kv", id: "kv-c" }, + DO_C: { + type: "durable-object", + worker: "worker-c", + exportName: "ScopedDO", + }, + WF_C: { + type: "workflow", + name: "workflow-c", + worker: "worker-c", + exportName: "MyWorkflowC", + }, + }, + exports: { + ScopedDO: { type: "durable-object", storage: "legacy-kv" }, }, }, }, @@ -576,6 +714,70 @@ describe("Multi-worker peer deduplication", () => { } `); }); + + test("only lists DO namespaces from peers in the same shared-storage scope", async ({ + expect, + }) => { + const response = await instanceA.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces` + ); + const data = (await response.json()) as ListResponse; + expect(normalizeListResponse(data)).toMatchObject({ + result: [ + { id: "worker-a-MyDO", name: "worker-a_MyDO" }, + { id: "worker-b1-OtherDO", name: "worker-b1_OtherDO" }, + { id: "worker-shared-SharedDO", name: "worker-shared_SharedDO" }, + ], + result_info: { count: 3 }, + }); + }); + + test("only lists workflows from peers in the same shared-storage scope", async ({ + expect, + }) => { + const response = await instanceA.dispatchFetch(`${BASE_URL}/workflows`); + const data = (await response.json()) as { + result?: Array<{ name: string }>; + result_info?: { count?: number }; + }; + expect(data.result).toMatchObject([ + { name: "workflow-a" }, + { name: "workflow-b" }, + ]); + expect(data.result_info?.count).toBe(2); + }); + + test("resolves DO namespace owner only within the same shared-storage scope", async ({ + expect, + }) => { + const responseSameScope = await instanceA.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/worker-b1-OtherDO/objects` + ); + expect(responseSameScope.status).toBe(200); + await responseSameScope.text(); + + const responseDiffScope = await instanceA.dispatchFetch( + `${BASE_URL}/workers/durable_objects/namespaces/worker-c-ScopedDO/objects` + ); + expect(responseDiffScope.status).toBe(404); + await responseDiffScope.text(); + }); + + test("resolves workflow owner only within the same shared-storage scope", async ({ + expect, + }) => { + const responseSameScope = await instanceA.dispatchFetch( + `${BASE_URL}/workflows/workflow-b` + ); + expect(responseSameScope.status).toBe(200); + await responseSameScope.text(); + + const responseDiffScope = await instanceA.dispatchFetch( + `${BASE_URL}/workflows/workflow-c` + ); + expect(responseDiffScope.status).toBe(404); + await responseDiffScope.text(); + }); }); describe("Same ID across multiple instances with different persistence directories", () => {