diff --git a/src/api/app-messaging.ts b/src/api/app-messaging.ts index 86677da3..5dbe904c 100644 --- a/src/api/app-messaging.ts +++ b/src/api/app-messaging.ts @@ -19,10 +19,88 @@ import { answerWebContextRequest } from "./web-context.ts"; import { validateUserSchedule } from "../cron/schedule.ts"; import type { App, AppDeps, ReachNowResult } from "./app-types.ts"; +import type { Deployment } from "../deploy/deploy-store.ts"; +import type { IngestEvent } from "../surface-cache/surface-cache.ts"; import { CONTEXT_REQUEST_EXPIRY_MS } from "./app-types.ts"; import type { AppHelpers } from "./app-helpers.ts"; import type { AmbientHelpers } from "./app-ambient.ts"; +function postedUrls(text: string): URL[] { + const urls: URL[] = []; + for (const match of text.matchAll(/https?:\/\/[^\s<>"'|]+/gi)) { + try { + urls.push(new URL(match[0]!.replace(/[),.;!?\]]+$/, ""))); + } catch { + // Ignore malformed links; message ingestion must remain best-effort. + } + } + return urls; +} + +function deploymentForPostedUrl( + url: URL, + deployments: Deployment[], + publicWebUrl: string | undefined, +): Deployment | undefined { + if (publicWebUrl) { + try { + const portal = new URL(publicWebUrl); + const parts = url.pathname.split("/").filter(Boolean); + if (url.origin === portal.origin && parts[0] === "d" && parts[1]) { + const slug = decodeURIComponent(parts[1]); + const deployment = deployments.find((d) => d.id === slug || d.name === slug); + if (deployment) return deployment; + } + } catch { + // A malformed deployment URL configuration cannot make an external URL trusted. + } + } + return deployments.find((deployment) => { + const raw = deployment.endpoint?.publicUrl; + if (!raw) return false; + try { + const published = new URL(raw); + const pathMatches = published.pathname.endsWith("/") + ? url.pathname.startsWith(published.pathname) + : url.pathname === published.pathname || url.pathname.startsWith(`${published.pathname}/`); + return url.origin === published.origin && pathMatches; + } catch { + return false; + } + }); +} + +async function shareDeploymentsPostedByOwner(deps: AppDeps, events: IngestEvent[]): Promise { + const candidates = events.flatMap((event) => { + if ( + event.self || + event.deleted || + !event.authorId || + !event.text || + (event.kind !== "channel" && event.kind !== "group") + ) + return []; + const urls = postedUrls(event.text); + return urls.length + ? [{ event, urls, authorId: event.authorId, audience: scopeId(event.kind, event.container) }] + : []; + }); + if (!candidates.length) return; + const deployments = await deps.deploy.listDeployments(); + for (const { urls, authorId, audience } of candidates) { + const seen = new Set(); + for (const url of urls) { + const deployment = deploymentForPostedUrl(url, deployments, deps.publicWebUrl); + if (!deployment || seen.has(deployment.id)) continue; + seen.add(deployment.id); + if (deployment.ownerScopeId !== scopeId("personal", authorId)) continue; + const grants = await deps.deploy.deploymentGrantees(deployment.id); + if (grants.some((grant) => grant.scope === audience)) continue; + await deps.deploy.shareDeployment(deployment.id, audience, "read", { createdBy: authorId }); + } + } +} + export function createMessagingMethods( deps: AppDeps, h: AppHelpers, @@ -235,7 +313,11 @@ export function createMessagingMethods( await deps.deliveries.enqueue(input); }, async ingestSurfaceEvents(events, surface = "slack", self) { - if (!deps.surfaceCache || !events.length) return { upserted: 0 }; + if (!events.length) return { upserted: 0 }; + await shareDeploymentsPostedByOwner(deps, events).catch((error) => + console.error("[deploy] failed to share a posted deployment link:", errMessage(error)), + ); + if (!deps.surfaceCache) return { upserted: 0 }; if (self && (self.name || self.mentionId)) ambientSelf.set(`${orgIdOf()}:${surface}`, self); const out = await deps.surfaceCache.ingest(events); for (const container of new Set(events.filter((e) => !e.self).map((e) => e.container))) { diff --git a/test/deployment-link-share.test.ts b/test/deployment-link-share.test.ts new file mode 100644 index 00000000..b7e1362e --- /dev/null +++ b/test/deployment-link-share.test.ts @@ -0,0 +1,99 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createApp } from "../src/api/app.ts"; +import { createDeployStore } from "../src/deploy/deploy-store.ts"; +import { createDeployService } from "../src/deploy/deploy-service.ts"; +import { createAclStore } from "../src/acl/acl-store.ts"; +import { createDirectoryStore } from "../src/directory/directory-store.ts"; +import { createIdentityService } from "../src/identity/identity-service.ts"; +import { createMemorySessionStore } from "../src/sessions/memory-session-store.ts"; +import { scopeId } from "../src/types.ts"; + +async function setup() { + const acl = createAclStore(); + const deploy = createDeployService({ + deployStore: createDeployStore(), + provider: { + profile: { managedScaleToZero: false }, + apply: async () => ({ + host: "127.0.0.1", + port: 19999, + publicUrl: "https://research-artifact.apps.example/", + }), + destroy: async () => {}, + }, + auditLog: { record() {}, events: async () => [], tail: async () => [] }, + acl, + deployDir: mkdtempSync(join(tmpdir(), "deployment-link-share-")), + }); + const directory = createDirectoryStore(); + await directory.replaceChannels( + [{ channelId: "CRESEARCH", name: "research", isPrivate: true }], + [ + { channelId: "CRESEARCH", principalId: "U1" }, + { channelId: "CRESEARCH", principalId: "U2" }, + ], + ); + const app = createApp({ + deploy, + acl, + directory, + sessions: createMemorySessionStore(), + identity: createIdentityService(), + publicWebUrl: "https://qm.example", + } as unknown as Parameters[0]); + const deployment = await app.deploy({ + ownerScopeId: scopeId("personal", "U1"), + createdBy: "U1", + entrypoint: "node server.js", + files: [], + name: "research-artifact", + }); + return { app, deployment }; +} + +test("posting an owned deployment URL shares it read-only with the conversation scope", async () => { + const { app, deployment } = await setup(); + assert.equal((await app.reachDeployment(deployment.id, "U2")).status, "denied"); + + await app.ingestSurfaceEvents([ + { + container: "CRESEARCH", + ts: "1.0", + authorId: "U1", + text: "Here it is: https://research-artifact.apps.example/", + kind: "channel", + }, + ]); + + assert.equal((await app.reachDeployment(deployment.id, "U2")).status, "ok"); + assert.deepEqual(await app.deploymentGrantees(deployment.id), [ + { scope: scopeId("channel", "CRESEARCH"), permission: "read" }, + ]); +}); + +test("trusted /d links share, while lookalike links and non-owner posts do not", async () => { + const first = await setup(); + await first.app.ingestSurfaceEvents([ + { + container: "CRESEARCH", + ts: "1.0", + authorId: "U1", + text: "", + kind: "channel", + }, + ]); + assert.equal((await first.app.reachDeployment(first.deployment.id, "U2")).status, "ok"); + + for (const [authorId, text] of [ + ["U1", "https://attacker.example/d/research-artifact/"], + ["U2", "https://research-artifact.apps.example/"], + ]) { + const { app, deployment } = await setup(); + await app.ingestSurfaceEvents([{ container: "CRESEARCH", ts: "2.0", authorId, text, kind: "channel" }]); + assert.equal((await app.reachDeployment(deployment.id, "U2")).status, "denied"); + } +});