Skip to content

Commit 4afe104

Browse files
16francejqm-yc
andcommitted
Share posted deployment links with their scope
Co-Authored-By: QM <qm@ycombinator.com>
1 parent cd1e9dd commit 4afe104

2 files changed

Lines changed: 182 additions & 1 deletion

File tree

src/api/app-messaging.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,88 @@ import { answerWebContextRequest } from "./web-context.ts";
1919
import { validateUserSchedule } from "../cron/schedule.ts";
2020

2121
import type { App, AppDeps, ReachNowResult } from "./app-types.ts";
22+
import type { Deployment } from "../deploy/deploy-store.ts";
23+
import type { IngestEvent } from "../surface-cache/surface-cache.ts";
2224
import { CONTEXT_REQUEST_EXPIRY_MS } from "./app-types.ts";
2325
import type { AppHelpers } from "./app-helpers.ts";
2426
import type { AmbientHelpers } from "./app-ambient.ts";
2527

28+
function postedUrls(text: string): URL[] {
29+
const urls: URL[] = [];
30+
for (const match of text.matchAll(/https?:\/\/[^\s<>"'|]+/gi)) {
31+
try {
32+
urls.push(new URL(match[0]!.replace(/[),.;!?\]]+$/, "")));
33+
} catch {
34+
// Ignore malformed links; message ingestion must remain best-effort.
35+
}
36+
}
37+
return urls;
38+
}
39+
40+
function deploymentForPostedUrl(
41+
url: URL,
42+
deployments: Deployment[],
43+
publicWebUrl: string | undefined,
44+
): Deployment | undefined {
45+
if (publicWebUrl) {
46+
try {
47+
const portal = new URL(publicWebUrl);
48+
const parts = url.pathname.split("/").filter(Boolean);
49+
if (url.origin === portal.origin && parts[0] === "d" && parts[1]) {
50+
const slug = decodeURIComponent(parts[1]);
51+
const deployment = deployments.find((d) => d.id === slug || d.name === slug);
52+
if (deployment) return deployment;
53+
}
54+
} catch {
55+
// A malformed deployment URL configuration cannot make an external URL trusted.
56+
}
57+
}
58+
return deployments.find((deployment) => {
59+
const raw = deployment.endpoint?.publicUrl;
60+
if (!raw) return false;
61+
try {
62+
const published = new URL(raw);
63+
const pathMatches = published.pathname.endsWith("/")
64+
? url.pathname.startsWith(published.pathname)
65+
: url.pathname === published.pathname || url.pathname.startsWith(`${published.pathname}/`);
66+
return url.origin === published.origin && pathMatches;
67+
} catch {
68+
return false;
69+
}
70+
});
71+
}
72+
73+
async function shareDeploymentsPostedByOwner(deps: AppDeps, events: IngestEvent[]): Promise<void> {
74+
const candidates = events.flatMap((event) => {
75+
if (
76+
event.self ||
77+
event.deleted ||
78+
!event.authorId ||
79+
!event.text ||
80+
(event.kind !== "channel" && event.kind !== "group")
81+
)
82+
return [];
83+
const urls = postedUrls(event.text);
84+
return urls.length
85+
? [{ event, urls, authorId: event.authorId, audience: scopeId(event.kind, event.container) }]
86+
: [];
87+
});
88+
if (!candidates.length) return;
89+
const deployments = await deps.deploy.listDeployments();
90+
for (const { urls, authorId, audience } of candidates) {
91+
const seen = new Set<string>();
92+
for (const url of urls) {
93+
const deployment = deploymentForPostedUrl(url, deployments, deps.publicWebUrl);
94+
if (!deployment || seen.has(deployment.id)) continue;
95+
seen.add(deployment.id);
96+
if (deployment.ownerScopeId !== scopeId("personal", authorId)) continue;
97+
const grants = await deps.deploy.deploymentGrantees(deployment.id);
98+
if (grants.some((grant) => grant.scope === audience)) continue;
99+
await deps.deploy.shareDeployment(deployment.id, audience, "read", { createdBy: authorId });
100+
}
101+
}
102+
}
103+
26104
export function createMessagingMethods(
27105
deps: AppDeps,
28106
h: AppHelpers,
@@ -235,7 +313,11 @@ export function createMessagingMethods(
235313
await deps.deliveries.enqueue(input);
236314
},
237315
async ingestSurfaceEvents(events, surface = "slack", self) {
238-
if (!deps.surfaceCache || !events.length) return { upserted: 0 };
316+
if (!events.length) return { upserted: 0 };
317+
await shareDeploymentsPostedByOwner(deps, events).catch((error) =>
318+
console.error("[deploy] failed to share a posted deployment link:", errMessage(error)),
319+
);
320+
if (!deps.surfaceCache) return { upserted: 0 };
239321
if (self && (self.name || self.mentionId)) ambientSelf.set(`${orgIdOf()}:${surface}`, self);
240322
const out = await deps.surfaceCache.ingest(events);
241323
for (const container of new Set(events.filter((e) => !e.self).map((e) => e.container))) {

test/deployment-link-share.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdtempSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { createApp } from "../src/api/app.ts";
7+
import { createDeployStore } from "../src/deploy/deploy-store.ts";
8+
import { createDeployService } from "../src/deploy/deploy-service.ts";
9+
import { createAclStore } from "../src/acl/acl-store.ts";
10+
import { createDirectoryStore } from "../src/directory/directory-store.ts";
11+
import { createIdentityService } from "../src/identity/identity-service.ts";
12+
import { createMemorySessionStore } from "../src/sessions/memory-session-store.ts";
13+
import { scopeId } from "../src/types.ts";
14+
15+
async function setup() {
16+
const acl = createAclStore();
17+
const deploy = createDeployService({
18+
deployStore: createDeployStore(),
19+
provider: {
20+
profile: { managedScaleToZero: false },
21+
apply: async () => ({
22+
host: "127.0.0.1",
23+
port: 19999,
24+
publicUrl: "https://research-artifact.apps.example/",
25+
}),
26+
destroy: async () => {},
27+
},
28+
auditLog: { record() {}, events: async () => [], tail: async () => [] },
29+
acl,
30+
deployDir: mkdtempSync(join(tmpdir(), "deployment-link-share-")),
31+
});
32+
const directory = createDirectoryStore();
33+
await directory.replaceChannels(
34+
[{ channelId: "CRESEARCH", name: "research", isPrivate: true }],
35+
[
36+
{ channelId: "CRESEARCH", principalId: "U1" },
37+
{ channelId: "CRESEARCH", principalId: "U2" },
38+
],
39+
);
40+
const app = createApp({
41+
deploy,
42+
acl,
43+
directory,
44+
sessions: createMemorySessionStore(),
45+
identity: createIdentityService(),
46+
publicWebUrl: "https://qm.example",
47+
} as unknown as Parameters<typeof createApp>[0]);
48+
const deployment = await app.deploy({
49+
ownerScopeId: scopeId("personal", "U1"),
50+
createdBy: "U1",
51+
entrypoint: "node server.js",
52+
files: [],
53+
name: "research-artifact",
54+
});
55+
return { app, deployment };
56+
}
57+
58+
test("posting an owned deployment URL shares it read-only with the conversation scope", async () => {
59+
const { app, deployment } = await setup();
60+
assert.equal((await app.reachDeployment(deployment.id, "U2")).status, "denied");
61+
62+
await app.ingestSurfaceEvents([
63+
{
64+
container: "CRESEARCH",
65+
ts: "1.0",
66+
authorId: "U1",
67+
text: "Here it is: https://research-artifact.apps.example/",
68+
kind: "channel",
69+
},
70+
]);
71+
72+
assert.equal((await app.reachDeployment(deployment.id, "U2")).status, "ok");
73+
assert.deepEqual(await app.deploymentGrantees(deployment.id), [
74+
{ scope: scopeId("channel", "CRESEARCH"), permission: "read" },
75+
]);
76+
});
77+
78+
test("trusted /d links share, while lookalike links and non-owner posts do not", async () => {
79+
const first = await setup();
80+
await first.app.ingestSurfaceEvents([
81+
{
82+
container: "CRESEARCH",
83+
ts: "1.0",
84+
authorId: "U1",
85+
text: "<https://qm.example/d/research-artifact/|open dashboard>",
86+
kind: "channel",
87+
},
88+
]);
89+
assert.equal((await first.app.reachDeployment(first.deployment.id, "U2")).status, "ok");
90+
91+
for (const [authorId, text] of [
92+
["U1", "https://attacker.example/d/research-artifact/"],
93+
["U2", "https://research-artifact.apps.example/"],
94+
]) {
95+
const { app, deployment } = await setup();
96+
await app.ingestSurfaceEvents([{ container: "CRESEARCH", ts: "2.0", authorId, text, kind: "channel" }]);
97+
assert.equal((await app.reachDeployment(deployment.id, "U2")).status, "denied");
98+
}
99+
});

0 commit comments

Comments
 (0)