From 446830cef6592a80e81f9ea67d67b556dc563f47 Mon Sep 17 00:00:00 2001 From: devaniketh Date: Sat, 5 Sep 2026 23:13:56 +0530 Subject: [PATCH 1/2] [miniflare] Scope Durable Object and Workflow local explorer peers by storageScope Scope Durable Object and Workflow peer aggregation and owner resolution in the Miniflare local explorer to peers sharing the same storageScope when Shared Storage is enabled. This brings DO and Workflow exploration into alignment with KV, D1, and R2 behavior and prevents cross-project access to local development state across instances configured with different persistence roots. Fixes #15479 --- ...cope-do-workflow-explorer-storage-scope.md | 7 + .../workers/local-explorer/resources/do.ts | 7 +- .../local-explorer/resources/workflows.ts | 7 +- .../local-explorer/aggregation.spec.ts | 191 ++++++++++++++++-- 4 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 .changeset/scope-do-workflow-explorer-storage-scope.md 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..aa51d9572ca 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; } @@ -124,7 +126,8 @@ export async function listDONamespaces(c: AppContext) { const allNamespaces = await aggregateListResults( c, localNamespaces, - "/workers/durable_objects/namespaces" + "/workers/durable_objects/namespaces", + { 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..d6611e52f29 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", + { 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..5166bbfa92c 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" }, }, }, }, @@ -512,11 +588,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 +674,69 @@ 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" }, + ], + result_info: { count: 2 }, + }); + }); + + 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", () => { From 950800ae4f3f251c80887b3e05842dfac8b6aecf Mon Sep 17 00:00:00 2001 From: devaniketh Date: Sun, 6 Sep 2026 09:07:21 +0530 Subject: [PATCH 2/2] [miniflare] Deduplicate Durable Object namespaces in local explorer peer aggregation Pass getKey: (namespace) => namespace.id to aggregateListResults for DO namespaces, preventing duplicate entries when same-scope peers advertise matching namespaces. --- .../workers/local-explorer/resources/do.ts | 8 ++-- .../local-explorer/resources/workflows.ts | 2 +- .../local-explorer/aggregation.spec.ts | 43 ++++++++++++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/miniflare/src/workers/local-explorer/resources/do.ts b/packages/miniflare/src/workers/local-explorer/resources/do.ts index aa51d9572ca..9e15c3efdab 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/do.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/do.ts @@ -120,14 +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", - { sharedStorageOnly: true } + { + 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 d6611e52f29..84d0913a29c 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/workflows.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/workflows.ts @@ -250,7 +250,7 @@ export async function listWorkflows(c: AppContext): Promise { c, localWorkflows, "/workflows", - { sharedStorageOnly: true } + { 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 5166bbfa92c..bb4f09e1d29 100644 --- a/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts @@ -542,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; @@ -567,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" }, }, }, }, @@ -686,8 +726,9 @@ describe("Multi-worker peer deduplication", () => { 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: 2 }, + result_info: { count: 3 }, }); });