Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion src/api/app-messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string>();
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,
Expand Down Expand Up @@ -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))) {
Expand Down
99 changes: 99 additions & 0 deletions test/deployment-link-share.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createApp>[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: "<https://qm.example/d/research-artifact/|open dashboard>",
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");
}
});