Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/core/sdk/src/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
//
// An artifact IS the stored JSX source plus the title/description an agent
// matches against ("show me my active users dashboard"). Owner-scoped like a
// connection: created at the `user` tier, readable through the same owner
// policy, so org-tier sharing later needs no new machinery.
// connection, and created at the `org` tier so every member of the tenant can
// read it through the same owner policy.
// ---------------------------------------------------------------------------

import { Option, Schema, type Effect } from "effect";
Expand Down
59 changes: 50 additions & 9 deletions packages/core/sdk/src/artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Predicate, Result } from "effect";
import { withQueryContext } from "@executor-js/fumadb/query";

import { makeTestExecutor } from "./testing";
import { createExecutor } from "./executor";
import { makeTestExecutor, makeTestWorkspaceHarness } from "./testing";
import type { ArtifactBinding } from "./artifact";
import { ConnectionName, IntegrationSlug } from "./ids";
import { ConnectionName, IntegrationSlug, Subject, Tenant } from "./ids";

const binding = (integration: string, owner: "user" | "org", name: string): ArtifactBinding => ({
integration: IntegrationSlug.make(integration),
Expand Down Expand Up @@ -34,7 +36,7 @@ describe("executor.artifacts", () => {
code: CODE,
});
expect(saved.id).not.toBe("");
expect(saved.owner).toBe("user");
expect(saved.owner).toBe("org");
expect(saved.title).toBe("Active users");
expect(saved.description).toBe("Daily active users over time");
expect(saved.code).toBe(CODE);
Expand Down Expand Up @@ -123,7 +125,7 @@ describe("executor.artifacts", () => {
expect(listed).toEqual([
{
id: saved.id,
owner: "user",
owner: "org",
title: "Active users",
description: "described",
preview: null,
Expand Down Expand Up @@ -250,16 +252,55 @@ describe("executor.artifacts", () => {
}),
);

it.effect("an executor with no subject cannot save a user artifact", () =>
it.effect("an executor with no subject can save an artifact", () =>
Effect.gen(function* () {
// Pure-org executors (host request paths with no member bound) have no
// user partition to file a personal artifact into.
const executor = yield* makeTestExecutor({ subject: null });
const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE }));
expect(Result.isFailure(result)).toBe(true);
const saved = yield* executor.artifacts.save({ title: "Shared", code: CODE });
expect(saved.owner).toBe("org");
expect((yield* executor.artifacts.list()).map((a) => a.title)).toEqual(["Shared"]);
}),
);

it.effect("another member of the tenant can open, rename and delete a saved artifact", () =>
Effect.gen(function* () {
const { config, executor: alice } = yield* makeTestWorkspaceHarness();
const bob = yield* createExecutor({
...config,
subject: Subject.make("bob"),
db: withQueryContext(config.testDb.db, { tenant: String(config.tenant), subject: "bob" }),
});
yield* Effect.addFinalizer(() => bob.close().pipe(Effect.ignore));

const saved = yield* alice.artifacts.save({ title: "Revenue", code: CODE });

expect((yield* bob.artifacts.list()).map((a) => a.title)).toEqual(["Revenue"]);
expect((yield* bob.artifacts.get(saved.id)).code).toBe(CODE);

yield* bob.artifacts.rename({ id: saved.id, title: "Revenue by month" });
expect((yield* alice.artifacts.get(saved.id)).title).toBe("Revenue by month");

yield* bob.artifacts.remove({ id: saved.id });
expect(yield* alice.artifacts.list()).toEqual([]);
}).pipe(Effect.scoped),
);

it.effect("another tenant cannot see a saved artifact", () =>
Effect.gen(function* () {
const { config, executor: alice } = yield* makeTestWorkspaceHarness();
const outsider = yield* createExecutor({
...config,
tenant: Tenant.make("other-tenant"),
subject: Subject.make("mallory"),
db: withQueryContext(config.testDb.db, { tenant: "other-tenant", subject: "mallory" }),
});
yield* Effect.addFinalizer(() => outsider.close().pipe(Effect.ignore));

yield* alice.artifacts.save({ title: "Revenue", code: CODE });

expect(yield* outsider.artifacts.list()).toEqual([]);
}).pipe(Effect.scoped),
);

describe("previews", () => {
const MARKUP = '<div class="p-4"><h2>Revenue</h2></div>';
const SETTLED = '<div class="p-4"><h2>Revenue</h2><p>12,480 active</p></div>';
Expand Down
5 changes: 2 additions & 3 deletions packages/core/sdk/src/core-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,8 @@ export const coreTables = defineTables({

// A saved generative-UI artifact — the JSX source a model produced, kept so
// it can be re-rendered later and matched by title/description from any MCP
// client. Owner-scoped like every other personal row: artifacts are created
// at the `user` tier, and the `org` tier the table already carries is what
// sharing will use later without new machinery.
// client. Owner-scoped like every other row: new artifacts are created at
// the `org` tier, so they are shared with the whole tenant.
artifact: ownedExecutorTable(
"artifact",
{
Expand Down
9 changes: 4 additions & 5 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6705,9 +6705,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// ------------------------------------------------------------------

// Reads take no explicit owner: the owner policy already narrows to the
// rows this binding may see (org rows plus this subject's own), which is
// exactly "visible to the bound owner scope" and stays correct unchanged
// when org-tier sharing lands. Writes are always `user` tier in v1.
// rows this binding may see (org rows plus this subject's own). New
// artifacts are created at the `org` tier, so every member of the tenant
// can open, edit and delete them; older `user`-tier rows stay personal.
const artifactById =
(id: string): CoreWhere =>
(b: AnyCb) =>
Expand Down Expand Up @@ -6836,9 +6836,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
yield* core.updateMany("artifact", { where, set });
return rowToArtifact({ ...existing, ...set });
}
yield* requireUserSubject("user");
const keys = yield* Effect.try({
try: () => ownedKeys("user"),
try: () => ownedKeys("org"),
catch: (cause) => storageFailureFromUnknown("invalid owner", cause),
});
const created = yield* core.create("artifact", {
Expand Down
Loading