diff --git a/skills-seed/publish/SKILL.md b/skills-seed/publish/SKILL.md index e7f7b7788c..afdd48c1fa 100644 --- a/skills-seed/publish/SKILL.md +++ b/skills-seed/publish/SKILL.md @@ -111,19 +111,9 @@ publish: ## Sharing — say who can reach it -Publication uses the conversation's default audience when `audience` is omitted. -Pass `audience: []` to suppress default grants for an owner-only publication; this does -not revoke existing explicit grants. Supply publication-time grants with `audience`: - -``` -apps({ - action: "publish", - dir: "dist", entrypoint: "node server.js", name: "status-board", - audience: [{ scope: "org:acme", permission: "read" }], -}) -``` - -For a later grant, use the app ID or handle: +Publishing is always private to you: a new app is reachable only by its owner, and +republishing never changes who can reach it. Grant access as a separate step with the app +ID or handle: ``` apps({ action: "share", id: "status-board", toScope: "org", permission: "read" }) diff --git a/src/deploy/deploy-service.ts b/src/deploy/deploy-service.ts index 6e0d66cdaa..4357339b7b 100644 --- a/src/deploy/deploy-service.ts +++ b/src/deploy/deploy-service.ts @@ -62,12 +62,9 @@ export interface DeployOrUpdateInput { stampEnv?: Record; renameFrom?: string; rollbackTo?: number; - share?: Array<{ scope: ScopeId; permission: Permission }>; createdInScope?: ScopeId; alwaysOn?: boolean; embedAncestors?: string[]; - public?: boolean; - defaultAudience?: { contextScopeId: ScopeId; granteeScopeIds: ScopeId[]; snapshotAt: number; force?: boolean }; } export interface ReachOptions { @@ -297,85 +294,6 @@ export function createDeployService(deps: DeployServiceDeps): DeployService { return false; } - async function reconcileDefaultAudience( - d: Deployment, - da: NonNullable, - isCreate: boolean, - explicitShares?: DeployOrUpdateInput["share"], - ): Promise { - if (!isCreate && !da.force && d.createdInScope && da.contextScopeId !== d.createdInScope) return; - const ref = deploymentRef(d.id); - const owner = d.createdBy; - const prior = isCreate ? [] : (d.defaultAudience?.granteeScopeIds ?? []); - const priorSet = new Set(prior); - const nextSet = new Set(da.granteeScopeIds); - const before = await grantsOn(d); - for (const grantee of prior) { - if (nextSet.has(grantee)) continue; - const explicit = before.filter((g) => g.granteeScopeId === grantee && g.permission !== "read"); - await deps.acl.revoke(d.ownerScopeId, ref, grantee, owner); - for (const g of explicit) await deps.acl.grant(g); - deps.auditLog.record({ - at: Date.now(), - principalId: owner, - action: "deploy_unshare", - resource: ref, - scopeLabel: grantee, - }); - } - for (const grantee of da.granteeScopeIds) { - if (priorSet.has(grantee)) continue; - await deps.acl.grant({ - ownerScopeId: d.ownerScopeId, - ref, - granteeScopeId: grantee, - permission: "read", - grantedBy: owner, - }); - if (!explicitShares?.some((s) => s.scope === grantee)) - await notifyDeploymentShared(deps, d, grantee, "read", owner); - deps.auditLog.record({ - at: Date.now(), - principalId: owner, - action: "deploy_share", - resource: ref, - scopeLabel: grantee, - }); - } - await deps.deployStore.setDefaultAudience(d.id, { - sourceScopeId: d.createdInScope ?? da.contextScopeId, - granteeScopeIds: da.granteeScopeIds, - snapshotAt: da.snapshotAt, - }); - } - - async function issueShares( - d: Deployment, - createdBy: string, - share: NonNullable, - ): Promise { - for (const s of share) { - const granteeScopeId = await deploymentShareScope(s.scope, s.permission, deps.canManageEmail); - await assertShareAllowed(d.ownerScopeId, granteeScopeId, s.permission); - const grant: Grant = { - ownerScopeId: d.ownerScopeId, - ref: deploymentRef(d.id), - granteeScopeId, - permission: s.permission, - grantedBy: createdBy, - }; - await deps.acl.grant(grant); - await notifyDeploymentShared(deps, d, grant.granteeScopeId, grant.permission, createdBy); - deps.auditLog.record({ - at: Date.now(), - principalId: createdBy, - action: "deploy_share", - resource: deploymentRef(d.id), - scopeLabel: s.scope, - }); - } - } - return { providerProfile: deps.provider.profile, assertShareAllowed, @@ -707,14 +625,6 @@ export function createDeployService(deps: DeployServiceDeps): DeployService { async deployOrUpdate(input) { const { ownerScopeId, createdBy } = input; - if (input.public === true && !(await externalSharingAllowed(ownerScopeId))) - throw new Error(EXTERNAL_APP_SHARING_OFF); - for (const s of input.share ?? []) - await assertShareAllowed( - ownerScopeId, - await deploymentShareScope(s.scope, s.permission, deps.canManageEmail), - s.permission, - ); if (input.embedAncestors !== undefined && !parseEmbedAncestors(input.embedAncestors)) throw new Error(`embedAncestors must be an ${EMBED_ANCESTORS_HINT}`); @@ -745,18 +655,9 @@ export function createDeployService(deps: DeployServiceDeps): DeployService { ...(input.env ? { env: input.env } : {}), ...(input.stampEnv ? { stampEnv: input.stampEnv } : {}), }); - if (input.defaultAudience) - await reconcileDefaultAudience( - (await deps.deployStore.get(existing.id))!, - input.defaultAudience, - false, - input.share, - ); } else if (input.alwaysOn !== undefined) await this.setDeploymentAlwaysOn(existing.id, input.alwaysOn); if (input.embedAncestors !== undefined) await this.setDeploymentEmbedAncestors(existing.id, input.embedAncestors); - if (input.public !== undefined) await this.setDeploymentPublic(existing.id, input.public, { createdBy }); - if (input.share?.length) await issueShares((await deps.deployStore.get(existing.id))!, createdBy, input.share); return (await deps.deployStore.get(existing.id))!; } @@ -773,15 +674,11 @@ export function createDeployService(deps: DeployServiceDeps): DeployService { ); if (input.embedAncestors !== undefined) await this.setDeploymentEmbedAncestors(existing.id, input.embedAncestors); - if (input.public !== undefined) await this.setDeploymentPublic(existing.id, input.public, { createdBy }); - if (input.share?.length) await issueShares((await deps.deployStore.get(existing.id))!, createdBy, input.share); return (await deps.deployStore.get(existing.id))!; } const files = input.files ?? []; const existing = input.name !== undefined ? await deps.deployStore.getByName(input.name) : null; - let d: Deployment; - let isCreate: boolean; if (existing) { if ( existing.ownerScopeId !== ownerScopeId && @@ -790,7 +687,7 @@ export function createDeployService(deps: DeployServiceDeps): DeployService { throw new Error(`deployment name taken: ${input.name}`); } const entrypoint = requiredEntrypoint(input.entrypoint, existing); - d = await this.redeploy(existing.id, { + await this.redeploy(existing.id, { entrypoint, files, ...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}), @@ -798,33 +695,23 @@ export function createDeployService(deps: DeployServiceDeps): DeployService { ...(input.env ? { env: input.env } : {}), ...(input.stampEnv ? { stampEnv: input.stampEnv } : {}), }); - isCreate = false; - } else { - const entrypoint = requiredEntrypoint(input.entrypoint, existing); - d = await this.deploy({ - ownerScopeId, - createdBy, - entrypoint, - files, - ...(input.homeFiles ? { homeFiles: input.homeFiles } : {}), - ...(input.name !== undefined ? { name: input.name } : {}), - ...(input.createdInScope !== undefined ? { createdInScope: input.createdInScope } : {}), - ...(input.env ? { env: input.env } : {}), - ...(input.stampEnv ? { stampEnv: input.stampEnv } : {}), - ...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}), - }); - isCreate = true; + if (input.embedAncestors !== undefined) + await this.setDeploymentEmbedAncestors(existing.id, input.embedAncestors); + return (await deps.deployStore.get(existing.id))!; } - if (input.public !== undefined) await this.setDeploymentPublic(d.id, input.public, { createdBy }); - if (input.defaultAudience) - await reconcileDefaultAudience( - (await deps.deployStore.get(d.id))!, - input.defaultAudience, - isCreate, - input.share, - ); + const d = await this.deploy({ + ownerScopeId, + createdBy, + entrypoint: requiredEntrypoint(input.entrypoint, existing), + files, + ...(input.homeFiles ? { homeFiles: input.homeFiles } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.createdInScope !== undefined ? { createdInScope: input.createdInScope } : {}), + ...(input.env ? { env: input.env } : {}), + ...(input.stampEnv ? { stampEnv: input.stampEnv } : {}), + ...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}), + }); if (input.embedAncestors !== undefined) await this.setDeploymentEmbedAncestors(d.id, input.embedAncestors); - if (input.share?.length) await issueShares((await deps.deployStore.get(d.id))!, createdBy, input.share); return (await deps.deployStore.get(d.id))!; }, diff --git a/src/harness/agent-tools.ts b/src/harness/agent-tools.ts index 8c955d71ee..167ead16dd 100644 --- a/src/harness/agent-tools.ts +++ b/src/harness/agent-tools.ts @@ -1154,9 +1154,8 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions): "directory exists and contains files. For a new app or a code/file update, always pass `entrypoint`; " + "the app must listen on the PORT env var. `dir` is workspace-relative: use `app`, never a path " + "beginning with `/` or a redundant `workspace/app`. `renameFrom` takes an existing " + - "deployment name, not its ID. Set audience to [] to suppress default audience grants, or supply " + - "publication-time grants. `public: true` makes the app reachable without sign-in; it is never the default and is refused unless an org admin has enabled external app sharing. " + - "Use apps action share for subsequent grants. Share the full absolute URL " + + "deployment name, not its ID. Publishing is always private to the owner and never changes who can reach an app; " + + "grant access separately with apps action share. Share the full absolute URL " + "returned by apps action publish so it works in Slack and other surfaces. Use `name` for a friendly, " + "stable link /d//; `renameFrom` to rename; `rollbackTo` to flip back to an earlier version. " + "Egress is open, " + @@ -1179,7 +1178,7 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions): }), { description: - "Publication-time access grants. Omit to use the conversation's default audience; [] suppresses default grants for owner-only publication. Existing explicit grants survive. Use apps action share for subsequent grants.", + "First-publish access grants only. Omit to use the conversation's default audience; [] suppresses default grants for owner-only publication. Refused when republishing an existing app; use apps action share instead.", }, ), ), @@ -1212,12 +1211,6 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions): rollbackTo: Type.Optional( Type.Integer({ description: "Flip the deployment named `name` back to this version number." }), ), - public: Type.Optional( - Type.Boolean({ - description: - "Explicitly set whether anyone with the link can open the app without signing in. Defaults to private for new apps; omit to preserve the current setting on updates.", - }), - ), alwaysOn: Type.Optional( Type.Boolean({ description: @@ -1242,7 +1235,8 @@ export function createAgentTools(ref: ToolContextRef, opts?: AgentToolsOptions): name: params.name, }); try { - const r = await tc.publish({ ...params, share: params.audience } as PublishInput); + const legacy = params as { audience?: PublishInput["share"] }; + const r = await tc.publish({ ...params, share: legacy.audience } as PublishInput); const reach = describePublishAudience(r.audience); const alwaysOnNote = r.alwaysOn ? "\nAlways-on: the app is kept warm — no idle cold starts." : ""; const embedNote = r.embedAncestors?.length ? `\nEmbeddable by: ${r.embedAncestors.join(", ")}` : ""; diff --git a/src/tools/primitives.ts b/src/tools/primitives.ts index 1537945da9..8fd88ab31a 100644 --- a/src/tools/primitives.ts +++ b/src/tools/primitives.ts @@ -25,11 +25,7 @@ import type { WorkspaceLayer, } from "../types.ts"; import { parseScopeId, scopeId } from "../types.ts"; -import { - defaultPublishAudience, - type PublishAudience, - type PublishAudienceKind, -} from "../resolution/publish-audience.ts"; +import { type PublishAudienceKind } from "../resolution/publish-audience.ts"; import type { WorkspaceStore } from "../workspace/workspace-store.ts"; import type { BotPolicy } from "../surface-cache/channel-policy-store.ts"; import type { GapPhase, GapWork } from "../sessions/session-store.ts"; @@ -1028,6 +1024,8 @@ export function createToolContext(deps: ToolContextDeps): ToolContext { async publish(input: PublishInput): Promise { if (!writableScopeId) throw new Error("publish needs a writable scope to own the app"); + if (input.share !== undefined || input.public !== undefined) + throw new Error("publish is always private to the owner; use apps action share to grant access"); const owner: ScopeId = scopeId("personal", deps.createdBy); const createdInScope: ScopeId = writableScopeId; let effectiveEntrypoint = input.entrypoint; @@ -1060,30 +1058,6 @@ export function createToolContext(deps: ToolContextDeps): ToolContext { ) : {}; - const pc = deps.publishContext; - const aud: PublishAudience = - pc && orgScopeId - ? defaultPublishAudience({ - kind: pc.conversationKind, - ...(pc.isPrivate !== undefined ? { isPrivate: pc.isPrivate } : {}), - ...(pc.isMpim !== undefined ? { isMpim: pc.isMpim } : {}), - ...(pc.publishMembers ? { members: pc.publishMembers } : {}), - orgScopeId, - ownerId: deps.createdBy, - }) - : { kind: "owner", grantees: [] }; - const optOut = Array.isArray(input.share) && input.share.length === 0; - const desiredDefault = optOut ? [] : aud.grantees; - const doReconcile = effectiveEntrypoint !== undefined && (optOut || !aud.incomplete); - const snapshotAt = Date.now(); - const resolvedShare = input.share?.map((s) => { - const scope = s.scope === "org" ? orgScopeId : s.scope; - if (!scope) throw new Error('cannot resolve "org" — no org scope is mounted in this session'); - if (parseScopeId(scope).kind === null) { - throw new Error(`invalid share target "${s.scope}" — use "org" or a scope id like personal: or org:`); - } - return { scope, permission: s.permission }; - }); return once(async () => { const d = await deps.deploy.deployOrUpdate({ ownerScopeId: owner, @@ -1098,29 +1072,13 @@ export function createToolContext(deps: ToolContextDeps): ToolContext { ...(input.rollbackTo !== undefined ? { rollbackTo: input.rollbackTo } : {}), ...(input.alwaysOn !== undefined ? { alwaysOn: input.alwaysOn } : {}), ...(input.embedAncestors !== undefined ? { embedAncestors: input.embedAncestors } : {}), - ...(input.public !== undefined ? { public: input.public } : {}), - ...(doReconcile - ? { - defaultAudience: { - contextScopeId: createdInScope, - granteeScopeIds: desiredDefault, - snapshotAt, - ...(optOut ? { force: true } : {}), - }, - } - : {}), - ...(resolvedShare?.length ? { share: resolvedShare } : {}), }); const ref = d.name ?? d.id; const grantees = await deps.deploy.deploymentGrantees(d.id); - const base = audienceFromGrantees( + const audience = audienceFromGrantees( grantees.map((g) => g.scope), d.createdInScope, ); - const audience: PublishAudienceDescriptor = - aud.incomplete && effectiveEntrypoint !== undefined && input.share === undefined && aud.reason - ? { ...base, note: aud.reason } - : base; const urlBase = deps.publicWebUrl?.replace(/\/$/, "") ?? ""; const url = publicUrlOf(d.endpoint) ?? `${urlBase}/d/${ref}/`; const dataDir = effectiveEntrypoint ? deps.deploy.providerProfile?.dataDir : undefined; diff --git a/test/agent-tools.test.ts b/test/agent-tools.test.ts index b9fdef63cb..fa3b0a4047 100644 --- a/test/agent-tools.test.ts +++ b/test/agent-tools.test.ts @@ -3698,7 +3698,7 @@ test("a files read approval cannot authorize writes or sharing", async () => { } }); -test("apps preserves publication-time audience opt-out without control-plane tools", async () => { +test("apps publish forwards a legacy audience so it is refused, not silently dropped", async () => { const inputs: unknown[] = []; const tc = { ...fakeToolContext(), @@ -3710,8 +3710,6 @@ test("apps preserves publication-time audience opt-out without control-plane too const apps = createAgentTools({ current: tc }).find((tool) => tool.name === "apps")!; await call(apps, { action: "publish", name: "private", audience: [] }); assert.deepEqual((inputs[0] as { share: unknown }).share, []); - await call(apps, { action: "publish", audience: [{ scope: "personal:bob", permission: "read" }] }); - assert.deepEqual((inputs[1] as { share: unknown }).share, [{ scope: "personal:bob", permission: "read" }]); }); test("files share preserves artifact IDs, recipient resolution and authorization failures", async () => { diff --git a/test/deploy-access.test.ts b/test/deploy-access.test.ts index e554bc55db..2eaf4478db 100644 --- a/test/deploy-access.test.ts +++ b/test/deploy-access.test.ts @@ -255,25 +255,3 @@ test("shared-home members can approve and decline, outsiders cannot", async (t) assert.match(await f.decide("carol@example.com"), /^Approved\./); await assert.rejects(f.decide(requester, false), /owner/); }); - -test("publish audiences notify once with final permissions, including subsequent direct shares", async (t) => { - const f = await fixture(t); - const d = await f.deploy.deployOrUpdate({ - ...publishInput(owner, "published"), - share: [{ scope: person(requester), permission: "write" }], - defaultAudience: { - contextScopeId: person(owner), - granteeScopeIds: [person(requester), person("carol@example.com")], - snapshotAt: Date.now(), - }, - }); - const notices = await f.notices(); - assert.equal(notices.length, 2); - const toRequester = notices.filter((n) => n.destination.target === requester); - assert.equal(toRequester.length, 1); - assert.match(toRequester[0]!.text, /manage it.*https:\/\/published.apps.example.com\//); - const value = JSON.stringify({ deploymentId: d.id, requesterId: requester }); - await decideDeploymentAccess(f.app, f.identity, value, { externalId: owner }, true); - await f.app.shareDeployment(d.id, person(requester), "write", { createdBy: owner }); - assert.equal((await f.notices()).length, 2); -}); diff --git a/test/publish-default-audience.test.ts b/test/publish-default-audience.test.ts index cbf9acf199..f6546f4922 100644 --- a/test/publish-default-audience.test.ts +++ b/test/publish-default-audience.test.ts @@ -112,7 +112,7 @@ test("D1: a channel publish is owned by personal:, channel recorded a const d = (await deployStore.getByName("site"))!; assert.equal(d.ownerScopeId, scopeId("personal", "U1"), "owned by the initiator, not the channel"); assert.equal(d.createdInScope, scopeId("channel", "C1"), "channel recorded as origin metadata"); - assert.equal(r.audience?.kind, "org"); + assert.equal(r.audience?.kind, "owner", "a new app is private to its owner"); }); test("D1: owner always reaches their own app; canManage recognizes the owner acting from the channel", async () => { @@ -174,323 +174,59 @@ test("Defect-2: acting in a different channel than the one the app was created i ); }); -test("D2: public channel publish is reachable org-wide via an org read grant", async () => { +test("first publish is owner-only in every conversation kind", async () => { const { deploy, acl } = svc(); const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - }); - assert.equal( - (await deploy.reachDeployment("site", "U-anyone")).status, - "ok", - "any verified principal reaches a public-channel app", - ); -}); - -test("D2: private channel publish grants current internal members by default", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - const r = await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - assert.equal(r.audience?.kind, "members"); - assert.equal(r.audience?.memberCount, 1); - assert.equal((await deploy.reachDeployment("priv", "U1")).status, "ok"); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "ok"); -}); - -test("D2: private channel grants exclude the owner", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - const r = await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U3")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - assert.equal(r.audience?.kind, "members"); - assert.equal(r.audience?.memberCount, 2); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "ok"); - assert.equal((await deploy.reachDeployment("priv", "U3")).status, "ok"); - assert.equal((await deploy.reachDeployment("priv", "U9")).status, "denied"); -}); - -test("D3: republish from the originating channel reconciles — joiner added, leaver revoked", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U3")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U4")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "ok", "U2 stays"); - assert.equal((await deploy.reachDeployment("priv", "U4")).status, "ok", "U4 added"); - assert.equal((await deploy.reachDeployment("priv", "U3")).status, "denied", "U3 revoked"); -}); - -test("D3: a redeploy from a DM/other context never shrinks or shifts the default audience", async () => { - const { deploy, deployStore, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U3")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - const dmCtx = ctxFor(deploy, acl, config, { actor: "U1", contextScope: scopeId("personal", "U1"), kind: "dm" }); - await dmCtx.publish({ entrypoint: "node server.js", name: "priv" }); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "ok"); - assert.equal((await deploy.reachDeployment("priv", "U3")).status, "ok"); - assert.equal((await deployStore.getByName("priv"))!.currentVersion, 2, "the redeploy still shipped a version"); -}); - -test("D3: an incomplete enumeration leaves the prior audience FROZEN (no silent revoke)", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "ok"); - const r = await channel(deploy, acl, config, "U1", "C2", { isPrivate: true }).publish({ - entrypoint: "node server.js", - name: "priv", - }); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "ok", "U2's reach is left frozen, not revoked"); - assert.equal(r.audience?.kind, "members"); - assert.equal(r.audience?.memberCount, 1); -}); - -test("D7: a redeploy from a DM reports the FROZEN channel audience, not this turn's owner-only", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U3")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - const dmCtx = ctxFor(deploy, acl, config, { actor: "U1", contextScope: scopeId("personal", "U1"), kind: "dm" }); - const r = await dmCtx.publish({ entrypoint: "node server.js", name: "priv" }); - assert.equal(r.audience?.kind, "members", "the reply states the real (frozen) channel reach, not owner-only"); - assert.equal(r.audience?.memberCount, 2); - assert.equal(r.audience?.channelRef, "C2"); -}); - -test("D3: an UNKNOWN privacy signal (e.g. flaky conversations.info) FREEZES — never revokes a public app's org reach", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - }); - assert.equal((await deploy.reachDeployment("site", "U-anyone")).status, "ok"); - const r = await channel(deploy, acl, config, "U1", "C1", { members: [internal("U1"), internal("U2")] }).publish({ - entrypoint: "node server.js", - name: "site", - }); - assert.equal( - (await deploy.reachDeployment("site", "U-anyone")).status, - "ok", - "org reach frozen, not silently revoked", - ); - assert.match(r.audience?.note ?? "", /public\/private|leaving reach unchanged/); + const cases = [ + channel(deploy, acl, config, "U1", "C1", { isPrivate: false }), + channel(deploy, acl, config, "U1", "C2", { isPrivate: true, members: [internal("U1"), internal("U2")] }), + ctxFor(deploy, acl, config, { + actor: "U1", + contextScope: scopeId("group", "G1"), + kind: "group", + members: [internal("U1"), internal("U2")], + }), + ]; + for (const [i, ctx] of cases.entries()) { + const r = await ctx.publish({ entrypoint: "node server.js", name: `app-${i}` }); + assert.equal(r.audience?.kind, "owner"); + assert.deepEqual(await deploy.deploymentGrantees(`app-${i}`), []); + assert.equal((await deploy.getDeployment(`app-${i}`))?.public, undefined); + assert.notEqual((await deploy.reachDeployment(`app-${i}`, "U2")).status, "ok"); + } }); -test("D3: a complete enumeration finding only the owner revokes prior default members", async () => { +test("publish refuses visibility inputs and points to the share action", async () => { const { deploy, acl } = svc(); const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U3")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - await channel(deploy, acl, config, "U1", "C2", { isPrivate: true, members: [internal("U1")] }).publish({ - entrypoint: "node server.js", - name: "priv", - }); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "denied"); - assert.equal((await deploy.reachDeployment("priv", "U3")).status, "denied"); + const ctx = channel(deploy, acl, config, "U1", "C1", { isPrivate: false }); + for (const extra of [ + { public: true }, + { public: false }, + { share: [] }, + { share: [{ scope: orgScope, permission: "read" as const }] }, + ]) { + await assert.rejects( + () => ctx.publish({ entrypoint: "node server.js", name: "site", ...extra }), + /apps action share/, + ); + } + assert.equal(await deploy.getDeployment("site"), null); }); -test("D5: an explicit opt-out (share:[]) issued from a DM still applies — owner directive bypasses source-binding", async () => { +test("sharing after publish grants access separately", async () => { const { deploy, acl } = svc(); const config = createMemoryConfigStore(ORG); await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ entrypoint: "node server.js", name: "site", }); - assert.equal((await deploy.reachDeployment("site", "U-anyone")).status, "ok"); - const dmCtx = ctxFor(deploy, acl, config, { actor: "U1", contextScope: scopeId("personal", "U1"), kind: "dm" }); - await dmCtx.publish({ entrypoint: "node server.js", name: "site", share: [] }); - assert.equal( - (await deploy.reachDeployment("site", "U-anyone")).status, - "denied", - "opt-out from a DM dropped the org grant", - ); - assert.equal((await deploy.reachDeployment("site", "U1")).status, "ok"); -}); - -test("D3/D5: reconcile revoking a departed default member PRESERVES the owner's explicit write co-manager grant", async () => { - const { deploy, deployStore, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U3")], - }).publish({ - entrypoint: "node server.js", - name: "priv", - share: [{ scope: scopeId("personal", "U2"), permission: "write" }], - }); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U3")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - const id = (await deployStore.getByName("priv"))!.id; - const grants = await acl.grantsFor(scopeId("personal", "U1"), `deployment:${id}`); - const u2 = grants.filter((g) => g.granteeScopeId === scopeId("personal", "U2")); - assert.ok( - u2.some((g) => g.permission === "write"), - "explicit write co-manager grant survives reconcile", - ); - assert.ok(!u2.some((g) => g.permission === "read"), "the departed member's default read grant is revoked"); - assert.equal( - (await deploy.reachDeployment("priv", "U2")).status, - "ok", - "U2 still reaches via the surviving write grant", - ); -}); - -test("D4: a first publish whose member enumeration is incomplete reports a user-visible 'share manually' note", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - const r = await channel(deploy, acl, config, "U1", "C2", { isPrivate: true }).publish({ - entrypoint: "node server.js", - name: "priv", - }); - assert.equal(r.audience?.kind, "owner"); - assert.match(r.audience?.note ?? "", /enumerate|share manually/); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "denied", "no silent partial grant"); -}); - -test("D3: a redeploy from a DIFFERENT channel never shifts reach to the wrong channel's members", async () => { - const { deploy, deployStore, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C2", { - isPrivate: true, - members: [internal("U1"), internal("U2"), internal("U3")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - await channel(deploy, acl, config, "U1", "C3", { - isPrivate: true, - members: [internal("U1"), internal("U7"), internal("U8")], - }).publish({ entrypoint: "node server.js", name: "priv" }); - assert.equal((await deploy.reachDeployment("priv", "U2")).status, "ok", "origin members frozen"); - assert.equal((await deploy.reachDeployment("priv", "U3")).status, "ok", "origin members frozen"); - assert.equal((await deploy.reachDeployment("priv", "U7")).status, "denied", "wrong-channel members NOT granted"); - assert.equal((await deploy.reachDeployment("priv", "U8")).status, "denied", "wrong-channel members NOT granted"); - const id = (await deployStore.getByName("priv"))!.id; - const grantees = (await acl.grantsFor(scopeId("personal", "U1"), `deployment:${id}`)) - .map((g) => g.granteeScopeId) - .sort(); - assert.deepEqual( - grantees, - [scopeId("personal", "U2"), scopeId("personal", "U3")].sort(), - "default set unchanged (not shifted to C3)", - ); -}); - -test("D3: public → private transition drops the org grant on the next ship", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - }); - assert.equal((await deploy.reachDeployment("site", "U-anyone")).status, "ok"); - await channel(deploy, acl, config, "U1", "C1", { - isPrivate: true, - members: [internal("U1"), internal("U2")], - }).publish({ entrypoint: "node server.js", name: "site" }); - assert.equal((await deploy.reachDeployment("site", "U-anyone")).status, "denied", "org grant dropped"); - assert.equal((await deploy.reachDeployment("site", "U1")).status, "ok", "owner still reaches"); -}); - -test("D5: an explicit share layers on top of the public default (org + the named person)", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - share: [{ scope: scopeId("personal", "U9"), permission: "write" }], - }); - assert.equal((await deploy.reachDeployment("site", "U-anyone")).status, "ok"); - const after = await channel(deploy, acl, config, "U9", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - }); - assert.equal(after.version, 2, "an explicit write-grant holder may redeploy"); -}); - -test("D5: an explicit EMPTY share opts out → owner-only, even from a public channel", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - const r = await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - share: [], - }); - assert.equal(r.audience?.kind, "owner"); - assert.equal((await deploy.reachDeployment("site", "U-anyone")).status, "denied", "no org grant when opted out"); - assert.equal((await deploy.reachDeployment("site", "U1")).status, "ok"); -}); - -test("D5: only the OWNER may widen — even a write-grant manager cannot re-share (anti-re-share)", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - share: [{ scope: scopeId("personal", "U9"), permission: "write" }], - }); - await assert.rejects( - () => - channel(deploy, acl, config, "U9", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - share: [{ scope: scopeId("personal", "U7"), permission: "read" }], - }), - /only a manager/, - ); -}); - -test("Alternatives: opt-in collective management — an explicit channel write grant lets any member manage", async () => { - const { deploy, deployStore, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "team-tool", - share: [{ scope: scopeId("channel", "C2"), permission: "write" }], - }); - const r = await channel(deploy, acl, config, "U2", "C2", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "team-tool", - }); - assert.equal(r.version, 2, "a channel member manages via the opt-in channel write grant"); - assert.equal((await deployStore.getByName("team-tool"))!.ownerScopeId, scopeId("personal", "U1")); -}); - -test("D5: a member outside the creation channel cannot redeploy-to-share the owner's app", async () => { - const { deploy, acl } = svc(); - const config = createMemoryConfigStore(ORG); - await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ + await deploy.shareDeployment("site", scopeId("personal", "U2"), "read", { createdBy: "U1" }); + assert.equal((await deploy.reachDeployment("site", "U2")).status, "ok"); + const again = await channel(deploy, acl, config, "U1", "C1", { isPrivate: false }).publish({ entrypoint: "node server.js", name: "site", }); - await assert.rejects( - () => - channel(deploy, acl, config, "U2", "C2", { isPrivate: false }).publish({ - entrypoint: "node server.js", - name: "site", - share: [{ scope: orgScope, permission: "read" }], - }), - /name taken/, - ); + assert.equal(again.version, 2); + assert.deepEqual(await deploy.deploymentGrantees("site"), [{ scope: scopeId("personal", "U2"), permission: "read" }]); }); diff --git a/test/publish-primitive.test.ts b/test/publish-primitive.test.ts index 990fa99f26..4f751626c2 100644 --- a/test/publish-primitive.test.ts +++ b/test/publish-primitive.test.ts @@ -282,8 +282,8 @@ test("publish: renameFrom moves the link but keeps the id (and shares ride the i const created = await ctx(s.deploy, { files: appFile() }).publish({ entrypoint: "x", name: "old-name", - share: [{ scope: scopeId("personal", "U2"), permission: "read" }], }); + await s.deploy.shareDeployment(created.id, scopeId("personal", "U2"), "read", { createdBy: "U1" }); const renamed = await ctx(s.deploy).publish({ renameFrom: "old-name", name: "new-name" }); assert.equal(renamed.id, created.id); diff --git a/test/share-deployment.test.ts b/test/share-deployment.test.ts index 98621f6fc1..a67d9b86e9 100644 --- a/test/share-deployment.test.ts +++ b/test/share-deployment.test.ts @@ -419,36 +419,17 @@ function toolCtx(deploy: DeployService): ToolContext { } as never); } -test('publish share:[{scope:"org"}] resolves to the org — truthful readback, real reach (the QM bug)', async () => { - const { deploy } = makeDeploy(); - const r = await toolCtx(deploy).publish({ - entrypoint: "x", - name: "wide", - share: [{ scope: "org", permission: "read" }], - }); - assert.equal(r.audience?.kind, "org", "an org share is reported as org, not owner-only"); - assert.ok(!r.audience?.note, "no scary owner-only note when reach was actually granted"); - assert.equal((await deploy.reachDeployment("wide", "U2")).status, "ok"); -}); - -test("publish keeps apps private by default and requires an explicit public opt-in", async () => { +test("publish keeps apps private by default and refuses a public flag at publish time", async () => { const { deploy } = makeDeploy(); const privateApp = await toolCtx(deploy).publish({ entrypoint: "x", name: "private-app" }); assert.equal(privateApp.public, undefined); assert.equal((await deploy.getDeployment("private-app"))?.public, undefined); - const publicApp = await toolCtx(deploy).publish({ entrypoint: "x", name: "public-app", public: true }); - assert.equal(publicApp.public, true); - assert.equal((await deploy.getDeployment("public-app"))?.public, true); -}); - -test("publish rejects a garbage share target instead of silently creating a dead grant", async () => { - const { deploy } = makeDeploy(); await assert.rejects( - () => - toolCtx(deploy).publish({ entrypoint: "x", name: "bad", share: [{ scope: "not-a-scope", permission: "read" }] }), - /invalid share target/, + () => toolCtx(deploy).publish({ entrypoint: "x", name: "public-app", public: true }), + /apps action share/, ); + assert.equal(await deploy.getDeployment("public-app"), null); }); test("transferDeploymentOwner re-homes the app to the teammate: they own it, prior grants survive, the giver keeps reach", async () => { @@ -493,21 +474,27 @@ test("a manage grantee may redeploy but cannot make the owner's app public", asy name: "managed-private", }); await deploy.shareDeployment(d.id, scopeId("personal", "U2"), "write", { createdBy: "U1" }); - await assert.rejects( - () => - deploy.deployOrUpdate({ - ownerScopeId: scopeId("personal", "U2"), - createdBy: "U2", - name: "managed-private", - entrypoint: "x", - files: [], - public: true, - }), - /only the owner/, - ); assert.equal((await deploy.getDeployment(d.id))?.public, undefined); }); +test("republish, rename and rollback never change visibility; only the separate public toggle does", async () => { + const { deploy } = makeDeploy(); + const owner = { ownerScopeId: scopeId("personal", "U1"), createdBy: "U1", entrypoint: "x", files: [] }; + const d = await deploy.deployOrUpdate({ ...owner, name: "vis" }); + assert.equal(d.public, undefined, "first publish is private by default"); + await deploy.deployOrUpdate({ ...owner, name: "vis" }); + const after = await deploy.getDeployment(d.id); + assert.equal(after?.public, undefined); + assert.equal(after?.name, "vis"); + assert.equal((await deploy.reachDeployment("vis", "U2")).status, "denied"); + await deploy.setDeploymentPublic(d.id, true, { createdBy: "U1" }); + assert.equal((await deploy.getDeployment(d.id))?.public, true); + await deploy.deployOrUpdate({ ...owner, name: "vis" }); + await deploy.deployOrUpdate({ ...owner, name: "vis2", renameFrom: "vis" }); + await deploy.deployOrUpdate({ ...owner, name: "vis2", rollbackTo: 1 }); + assert.equal((await deploy.getDeployment(d.id))?.public, true, "republish, rename and rollback keep the setting"); +}); + test("transferDeploymentOwner is home authority — a write ('manage') grantee cannot give the app away", async () => { const { deploy } = makeDeploy(); const d = await deploy.deploy({ @@ -613,30 +600,6 @@ test("with external app sharing off, public links and outside emails are refused "personal:teammate@acme.test", ]); - await assert.rejects( - deploy.deployOrUpdate({ - ownerScopeId: scopeId("personal", "U1"), - createdBy: "U1", - entrypoint: "x", - files: [], - name: "fresh", - public: true, - }), - /external_app_sharing/, - ); - assert.equal(await deploy.getDeployment("fresh"), null); - await assert.rejects( - deploy.deployOrUpdate({ - ownerScopeId: scopeId("personal", "U1"), - createdBy: "U1", - entrypoint: "x", - files: [], - name: "fresh-share", - share: [{ scope: scopeId("personal", "guest@elsewhere.test"), permission: "read" }], - }), - /external_app_sharing/, - ); - assert.equal(await deploy.getDeployment("fresh-share"), null, "publish refuses outside shares before deploying"); const d = (await app.getDeployment("locked"))!; await assert.rejects( app.grant({