From e47070b03c9d41065b3ae2e6082f25daa55310ca Mon Sep 17 00:00:00 2001 From: Felipe Torres Date: Tue, 20 Oct 2026 07:22:09 -0700 Subject: [PATCH] feat(artifacts): list only your own artifacts, and share a personal one with the org Org-tier artifacts stay openable, editable and deletable by every member through their link, but the artifact list (the gallery and the MCP list tool) now shows only the caller's own: their personal artifacts, org artifacts they created, and org artifacts with no recorded creator. A new nullable `created_by` column records the creating subject on save. Overwrites, renames and preview updates leave it alone. POST /artifacts/:artifactId/share moves one of the caller's personal artifacts to the org tier and records them as its creator. Another member's artifact, an unknown id and an already shared artifact all return 404. The detail page shows Share for a personal artifact and Copy link for a shared one. Includes the cloud migration for the new column and a D1 upgrade test. --- apps/cloud/drizzle/0023_groovy_titania.sql | 1 + apps/cloud/drizzle/meta/0023_snapshot.json | 1808 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/db/executor-schema.ts | 1 + apps/host-cloudflare/src/db/d1.test.ts | 22 + packages/core/api/src/artifacts/api.ts | 9 + .../api/src/artifacts/artifacts-share.test.ts | 93 + packages/core/api/src/handlers/artifacts.ts | 10 + packages/core/sdk/src/artifact.ts | 4 + packages/core/sdk/src/artifacts.test.ts | 171 +- packages/core/sdk/src/core-schema.ts | 5 + packages/core/sdk/src/executor.ts | 46 + packages/core/sdk/src/index.ts | 1 + packages/react/src/api/analytics.tsx | 1 + packages/react/src/api/atoms.tsx | 2 + packages/react/src/pages/artifact-detail.tsx | 56 + 16 files changed, 2236 insertions(+), 1 deletion(-) create mode 100644 apps/cloud/drizzle/0023_groovy_titania.sql create mode 100644 apps/cloud/drizzle/meta/0023_snapshot.json create mode 100644 packages/core/api/src/artifacts/artifacts-share.test.ts diff --git a/apps/cloud/drizzle/0023_groovy_titania.sql b/apps/cloud/drizzle/0023_groovy_titania.sql new file mode 100644 index 0000000000..612c79078b --- /dev/null +++ b/apps/cloud/drizzle/0023_groovy_titania.sql @@ -0,0 +1 @@ +ALTER TABLE "artifact" ADD COLUMN "created_by" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0023_snapshot.json b/apps/cloud/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000000..9494b57f86 --- /dev/null +++ b/apps/cloud/drizzle/meta/0023_snapshot.json @@ -0,0 +1,1808 @@ +{ + "id": "151e78d4-1dc9-471b-b44b-df11ee206c2d", + "prevId": "18cfbe2f-8cc3-4c9f-8501-680178204093", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_fingerprint": { + "name": "compiled_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_js": { + "name": "compiled_js", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_css": { + "name": "compiled_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "tools_sync_started_at": { + "name": "tools_sync_started_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "refresh_lease_until": { + "name": "refresh_lease_until", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "refresh_lease_holder": { + "name": "refresh_lease_holder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_refreshed_at": { + "name": "oauth_refreshed_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 07930eac04..d499dd5953 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1792307915126, "tag": "0022_productive_anthem", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1792505978902, + "tag": "0023_groovy_titania", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 2721636357..a3f2a4a997 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -245,6 +245,7 @@ export const artifact = pgTable( compiled_fingerprint: text("compiled_fingerprint"), compiled_js: text("compiled_js"), compiled_css: text("compiled_css"), + created_by: text("created_by"), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), row_id: varchar("row_id", { length: 255 }) diff --git a/apps/host-cloudflare/src/db/d1.test.ts b/apps/host-cloudflare/src/db/d1.test.ts index b9a95b0b8e..d4eb08191a 100644 --- a/apps/host-cloudflare/src/db/d1.test.ts +++ b/apps/host-cloudflare/src/db/d1.test.ts @@ -191,6 +191,28 @@ describe("createD1ExecutorDb", () => { await sqlite.close(); }); + it("adds the created_by artifact column to a database that predates it", async () => { + const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const { db } = makeRecordingD1(sqlite.client); + const first = await createD1ExecutorDb(db, undefined); + await first.close(); + await sqlite.client.execute(`ALTER TABLE "artifact" DROP COLUMN "created_by"`); + await sqlite.client.execute( + `UPDATE private_executor_cloudflare_schema_fingerprint + SET fingerprint = 'stale' + WHERE id = 'runtime-schema'`, + ); + + const second = await createD1ExecutorDb(db, undefined); + await second.close(); + + const columns = await sqlite.client.execute(`PRAGMA table_info("artifact")`); + const names = columns.rows.map((row) => String(row.name)); + expect(names).toEqual(expect.arrayContaining(["created_by"])); + + await sqlite.close(); + }); + it("converges safely when concurrent opens see the same stale fingerprint", async () => { const sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); const { db } = makeRecordingD1(sqlite.client); diff --git a/packages/core/api/src/artifacts/api.ts b/packages/core/api/src/artifacts/api.ts index 47ac60c89f..950a681b12 100644 --- a/packages/core/api/src/artifacts/api.ts +++ b/packages/core/api/src/artifacts/api.ts @@ -154,6 +154,15 @@ export const ArtifactsApi = HttpApiGroup.make("artifacts") error: [InternalError, ArtifactNotFound], }), ) + .add( + // Moves one of the caller's personal artifacts to the org tier. Another + // member's, an unknown id and an already-shared one all read as 404. + HttpApiEndpoint.post("share", "/artifacts/:artifactId/share", { + params: ArtifactParams, + success: ArtifactResponse, + error: [InternalError, ArtifactNotFound], + }), + ) .add( HttpApiEndpoint.delete("remove", "/artifacts/:artifactId", { params: ArtifactParams, diff --git a/packages/core/api/src/artifacts/artifacts-share.test.ts b/packages/core/api/src/artifacts/artifacts-share.test.ts new file mode 100644 index 0000000000..6a83807d07 --- /dev/null +++ b/packages/core/api/src/artifacts/artifacts-share.test.ts @@ -0,0 +1,93 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; + +import { createExecutor, type Executor } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; + +import { ExecutorApi } from "../api"; +import { observabilityMiddleware } from "../observability"; +import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; + +const webHandlerFor = (executor: Executor) => + Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(ExecutorApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(observabilityMiddleware(ExecutorApi)), + Layer.provide(Layer.succeed(ExecutorService)(executor)), + Layer.provide( + Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ), + { disableLogger: true }, + ), + ), + (web) => Effect.promise(() => web.dispose()), + ); + +const handlerContextFor = (executor: Executor) => + Context.make(ExecutorService, executor).pipe( + Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), + ); + +describe("artifact share over HTTP", () => { + it.effect("moves a personal artifact to the org tier", () => + Effect.gen(function* () { + const config = makeTestConfig(); + const executor = yield* createExecutor(config); + const context = { tenant: "test-tenant", subject: "test-subject" }; + yield* Effect.promise(() => + config.db.create("artifact", { + ...context, + owner: "user", + id: "art_mine", + title: "Mine", + description: null, + code: "const a = 1;", + bindings: null, + preview: null, + compiled_fingerprint: null, + compiled_js: null, + compiled_css: null, + created_by: null, + created_at: new Date(), + updated_at: new Date(), + }), + ); + const web = yield* webHandlerFor(executor); + + const response = yield* Effect.promise(() => + web.handler( + new Request("http://localhost/artifacts/art_mine/share", { method: "POST" }), + handlerContextFor(executor), + ), + ); + + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => response.json())) as { owner: string; id: string }; + expect(body.owner).toBe("org"); + expect(body.id).toBe("art_mine"); + }), + ); + + it.effect("answers 404 for an unknown id", () => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig()); + const web = yield* webHandlerFor(executor); + + const response = yield* Effect.promise(() => + web.handler( + new Request("http://localhost/artifacts/art_missing/share", { method: "POST" }), + handlerContextFor(executor), + ), + ); + + expect(response.status).toBe(404); + }), + ); +}); diff --git a/packages/core/api/src/handlers/artifacts.ts b/packages/core/api/src/handlers/artifacts.ts index 0b74cba015..2c59d5ae87 100644 --- a/packages/core/api/src/handlers/artifacts.ts +++ b/packages/core/api/src/handlers/artifacts.ts @@ -90,6 +90,16 @@ export const ArtifactsHandlers = HttpApiBuilder.group(ExecutorApi, "artifacts", }), ), ) + .handle("share", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const shared = yield* executor.artifacts.share({ id: path.artifactId }); + yield* notifyArtifactUsage("updated"); + return artifactToResponse(shared); + }), + ), + ) .handle("remove", ({ params: path }) => capture( Effect.gen(function* () { diff --git a/packages/core/sdk/src/artifact.ts b/packages/core/sdk/src/artifact.ts index 56f9c6ed1c..678ed2a2e5 100644 --- a/packages/core/sdk/src/artifact.ts +++ b/packages/core/sdk/src/artifact.ts @@ -218,6 +218,10 @@ export interface RenameArtifactInput { readonly title: string; } +export interface ShareArtifactInput { + readonly id: string; +} + export interface RemoveArtifactInput { readonly id: string; } diff --git a/packages/core/sdk/src/artifacts.test.ts b/packages/core/sdk/src/artifacts.test.ts index e875f8428b..5a8eb1e147 100644 --- a/packages/core/sdk/src/artifacts.test.ts +++ b/packages/core/sdk/src/artifacts.test.ts @@ -273,7 +273,7 @@ describe("executor.artifacts", () => { 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.list()).toEqual([]); expect((yield* bob.artifacts.get(saved.id)).code).toBe(CODE); yield* bob.artifacts.rename({ id: saved.id, title: "Revenue by month" }); @@ -301,6 +301,175 @@ describe("executor.artifacts", () => { }).pipe(Effect.scoped), ); + describe("sharing", () => { + const makeMember = ( + config: Parameters[0] & { + readonly testDb: { readonly db: Parameters[0] }; + }, + name: string, + ) => + Effect.gen(function* () { + const member = yield* createExecutor({ + ...config, + subject: Subject.make(name), + db: withQueryContext(config.testDb.db, { tenant: String(config.tenant), subject: name }), + }); + yield* Effect.addFinalizer(() => member.close().pipe(Effect.ignore)); + return member; + }); + + // Insert a row straight through the storage layer, bypassing `save`, so a + // test can pin the tier and creator that `save` would never produce. + const insertRow = ( + config: { readonly testDb: { readonly db: Parameters[0] } }, + context: { readonly tenant: string; readonly subject: string }, + row: { + readonly id: string; + readonly owner: "org" | "user"; + readonly subject: string; + readonly createdBy: string | null; + }, + ) => + Effect.promise(() => + withQueryContext(config.testDb.db, context).create("artifact", { + tenant: context.tenant, + owner: row.owner, + subject: row.subject, + id: row.id, + title: "Legacy", + description: null, + code: CODE, + bindings: null, + preview: null, + compiled_fingerprint: null, + compiled_js: null, + compiled_css: null, + created_by: row.createdBy, + created_at: new Date(), + updated_at: new Date(), + }), + ); + + it.effect("lists only the artifacts the member created, yet others can still open them", () => + Effect.gen(function* () { + const { config, executor: alice } = yield* makeTestWorkspaceHarness(); + const bob = yield* makeMember(config, "bob"); + + const saved = yield* alice.artifacts.save({ title: "Revenue", code: CODE }); + + expect((yield* alice.artifacts.list()).map((a) => a.id)).toEqual([saved.id]); + expect(yield* bob.artifacts.list()).toEqual([]); + expect((yield* bob.artifacts.get(saved.id)).code).toBe(CODE); + }).pipe(Effect.scoped), + ); + + it.effect("lists an org artifact with no recorded creator for every member", () => + Effect.gen(function* () { + const { config, executor: alice } = yield* makeTestWorkspaceHarness(); + const bob = yield* makeMember(config, "bob"); + yield* insertRow( + config, + { tenant: String(config.tenant), subject: "bob" }, + { id: "art_legacy", owner: "org", subject: "", createdBy: null }, + ); + + expect((yield* alice.artifacts.list()).map((a) => a.id)).toEqual(["art_legacy"]); + expect((yield* bob.artifacts.list()).map((a) => a.id)).toEqual(["art_legacy"]); + }).pipe(Effect.scoped), + ); + + it.effect("an executor with no subject lists every org artifact", () => + Effect.gen(function* () { + const { config, executor: alice } = yield* makeTestWorkspaceHarness(); + const orgOnly = yield* createExecutor({ + ...config, + subject: undefined, + db: withQueryContext(config.testDb.db, { + tenant: String(config.tenant), + subject: null, + }), + }); + yield* Effect.addFinalizer(() => orgOnly.close().pipe(Effect.ignore)); + + const saved = yield* alice.artifacts.save({ title: "Revenue", code: CODE }); + + expect((yield* orgOnly.artifacts.list()).map((a) => a.id)).toEqual([saved.id]); + }).pipe(Effect.scoped), + ); + + it.effect("sharing a personal artifact makes it openable by others but not listed", () => + Effect.gen(function* () { + const { config, executor: alice } = yield* makeTestWorkspaceHarness(); + const bob = yield* makeMember(config, "bob"); + yield* insertRow( + config, + { tenant: String(config.tenant), subject: "test-subject" }, + { id: "art_mine", owner: "user", subject: "test-subject", createdBy: null }, + ); + + const before = yield* Effect.result(bob.artifacts.get("art_mine")); + expect( + Result.isFailure(before) && Predicate.isTagged("ArtifactNotFoundError")(before.failure), + ).toBe(true); + expect((yield* alice.artifacts.get("art_mine")).owner).toBe("user"); + + const shared = yield* alice.artifacts.share({ id: "art_mine" }); + + expect(shared.owner).toBe("org"); + expect((yield* bob.artifacts.get("art_mine")).owner).toBe("org"); + expect((yield* bob.artifacts.list()).map((a) => a.id)).toEqual([]); + expect((yield* alice.artifacts.list()).map((a) => a.id)).toEqual(["art_mine"]); + }).pipe(Effect.scoped), + ); + + it.effect("another member cannot share a personal artifact", () => + Effect.gen(function* () { + const { config, executor: alice } = yield* makeTestWorkspaceHarness(); + const bob = yield* makeMember(config, "bob"); + yield* insertRow( + config, + { tenant: String(config.tenant), subject: "test-subject" }, + { id: "art_mine", owner: "user", subject: "test-subject", createdBy: null }, + ); + + const result = yield* Effect.result(bob.artifacts.share({ id: "art_mine" })); + + expect( + Result.isFailure(result) && Predicate.isTagged("ArtifactNotFoundError")(result.failure), + ).toBe(true); + expect((yield* alice.artifacts.get("art_mine")).owner).toBe("user"); + expect((yield* alice.artifacts.list()).map((a) => a.id)).toEqual(["art_mine"]); + }).pipe(Effect.scoped), + ); + + it.effect("sharing an artifact that is already org-tier fails", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ title: "Revenue", code: CODE }); + + const result = yield* Effect.result(executor.artifacts.share({ id: saved.id })); + + expect( + Result.isFailure(result) && Predicate.isTagged("ArtifactNotFoundError")(result.failure), + ).toBe(true); + }), + ); + + it.effect("overwriting another member's artifact does not change who lists it", () => + Effect.gen(function* () { + const { config, executor: alice } = yield* makeTestWorkspaceHarness(); + const bob = yield* makeMember(config, "bob"); + const saved = yield* alice.artifacts.save({ title: "Revenue", code: CODE }); + + yield* bob.artifacts.save({ id: saved.id, title: "Revenue v2", code: CODE }); + yield* bob.artifacts.rename({ id: saved.id, title: "Revenue v3" }); + + expect((yield* alice.artifacts.list()).map((a) => a.title)).toEqual(["Revenue v3"]); + expect(yield* bob.artifacts.list()).toEqual([]); + }).pipe(Effect.scoped), + ); + }); + describe("previews", () => { const MARKUP = '

Revenue

'; const SETTLED = '

Revenue

12,480 active

'; diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 0593c4f2f7..dc80fcd3c4 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -434,6 +434,11 @@ export const coreTables = defineTables({ compiled_fingerprint: nullableTextColumn("compiled_fingerprint"), compiled_js: nullableTextColumn("compiled_js"), compiled_css: nullableTextColumn("compiled_css"), + // The subject that first saved it. Org rows carry the empty-string + // `subject` sentinel, so this is what lets a gallery list only the + // caller's own artifacts. Null for org rows saved before it existed + // (creator unknown); never rewritten by an overwrite or rename. + created_by: nullableTextColumn("created_by"), created_at: dateColumn("created_at"), updated_at: dateColumn("updated_at"), }, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index f544b97988..b7c17b4feb 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -98,6 +98,7 @@ import { type ArtifactSummary, type RemoveArtifactInput, type RenameArtifactInput, + type ShareArtifactInput, type SaveArtifactInput, type SetArtifactPreviewInput, } from "./artifact"; @@ -531,6 +532,16 @@ export type Executor = { readonly rename: ( input: RenameArtifactInput, ) => Effect.Effect; + /** + * Move one of the caller's personal (`user`-tier) artifacts to the org + * tier, so every member can open it by id. The creator is recorded and the + * artifact stays in the caller's list. Fails with `ArtifactNotFoundError` + * for an id that is not the caller's own personal artifact (unknown, + * another member's, or already shared). + */ + readonly share: ( + input: ShareArtifactInput, + ) => Effect.Effect; readonly remove: (input: RemoveArtifactInput) => Effect.Effect; /** Upgrade the stored preview to a snapshot of a settled render. Touches * only `preview`, and never `updated_at`. */ @@ -6713,9 +6724,24 @@ export const createExecutor = b("id", "=", id); + // A member's gallery lists their own artifacts: personal rows (the policy + // already narrows those to this subject), org rows they created, and org + // rows with no recorded creator so legacy ones are not lost. Org rows by + // other members stay reachable by id, just not listed. A pure-org executor + // has no subject to filter by. const artifactsList = (): Effect.Effect => core .findMany("artifact", { + ...(subject == null + ? {} + : { + where: (b: AnyCb) => + b.or( + b("owner", "=", "user"), + b("created_by", "=", subject), + b.isNull("created_by"), + ), + }), // Newest first. `id` breaks ties so two artifacts sharing an // `updated_at` millisecond list in a repeatable order rather than // whatever the storage engine happens to return; which of the two @@ -6851,6 +6877,7 @@ export const createExecutor = => + Effect.gen(function* () { + const where = (b: AnyCb) => b.and(b("id", "=", input.id), b("owner", "=", "user")); + const existing = yield* core.findFirst("artifact", { where }); + if (!existing) { + return yield* new ArtifactNotFoundError({ id: ArtifactId.make(input.id) }); + } + const set = { owner: "org", subject: ORG_SUBJECT, created_by: existing.subject }; + yield* core.updateMany("artifact", { where, set }); + return rowToArtifact({ ...existing, ...set }); + }); + // Hard delete: an artifact is not a connection, so there is no disabled or // archived state to fall back to. const artifactsRemove = (input: RemoveArtifactInput): Effect.Effect => @@ -7895,6 +7940,7 @@ export const createExecutor = { + const exit = await doShare({ + params: { artifactId: props.artifactId }, + reactivityKeys: artifactWriteKeys, + }); + trackEvent("artifact_shared", { success: Exit.isSuccess(exit) }); + if (Exit.isFailure(exit)) { + toast.error("Couldn't share the artifact. Try again."); + return; + } + refresh(); + toast.success("Shared. Anyone in your organization can open this link."); + }; + + const handleCopyLink = () => + navigator.clipboard.writeText(globalThis.window.location.href).then( + () => toast.success("Link copied"), + () => toast.error("Couldn't copy the link. Copy it from the address bar."), + ); + const handleRemove = async () => { const exit = await doRemove({ params: { artifactId: props.artifactId }, @@ -145,6 +167,40 @@ export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { } /> + {artifact.value.owner === "user" ? ( + + + + + + + + Share {artifact.value.title} with your organization? + + + Everyone in your organization will be able to open, edit and delete it from + this link. It won't appear in their artifact list. + + + + Cancel + void handleShare()}>Share + + + + ) : ( + + )}