Skip to content

Commit f99d639

Browse files
authored
Reimplement sharing functionality in playground based off of serverless functions (#72)
1 parent 7e4cfa6 commit f99d639

File tree

9 files changed

+2243
-125
lines changed

9 files changed

+2243
-125
lines changed

README.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ vercel deploy --prebuilt
6464

6565
> ℹ️ Git Large File Storage (LFS) must be enabled in your Vercel project settings.
6666
67+
#### Enabling sharing functionality
68+
69+
To enable the sharing functionality on Vercel, you need to configure the following environment variables in your Vercel project settings:
70+
71+
**Required for sharing:**
72+
73+
- `S3_ENDPOINT` - Your S3-compatible storage endpoint (e.g., `https://s3.amazonaws.com` for AWS S3)
74+
- `S3_BUCKET` - Name of the S3 bucket to store shared playground data
75+
- `SHARE_SALT` - A random string used for generating share hashes (keep this secret)
76+
- `AWS_ACCESS_KEY_ID` - AWS access key for S3 access
77+
- `AWS_SECRET_ACCESS_KEY` - AWS secret key for S3 access
78+
- `VITE_SHARE_API_ENDPOINT` - The URL of your deployed Vercel instance (e.g., `https://your-playground.vercel.app`)
79+
80+
**Optional:**
81+
82+
- `VITE_GOOGLE_ANALYTICS_MEASUREMENT_ID` - Google Analytics measurement ID
83+
- `VITE_DISCORD_CHANNEL_ID` - Discord channel ID for embedded chat
84+
- `VITE_DISCORD_SERVER_ID` - Discord server ID
85+
- `VITE_DISCORD_INVITE_URL` - Discord invite URL (defaults to https://authzed.com/discord)
86+
87+
You can set these environment variables in the Vercel dashboard under your project's Settings > Environment Variables.
88+
6789
### NodeJS
6890

6991
The `build` directory in the project root directory after running `yarn build` will contain an optimized production React application that can be served using your preferred NodeJS hosting method.
@@ -92,6 +114,14 @@ Install LFS: `git lfs install`
92114
yarn run dev
93115
```
94116

117+
### Running for development with sharing enabled
118+
119+
The `vercel` CLI can be used to run locally with sharing enabled:
120+
121+
```
122+
VITE_SHARE_API_ENDPOINT=http://localhost:3000 SHARE_SALT=... AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... S3_ENDPOINT=... S3_BUCKET=... vercel dev
123+
```
124+
95125
## Updating wasm dependencies
96126

97127
The project contains prebuilt WASM files for versions of both SpiceDB and zed. To update the versions, edit the [wasm-config.json] file with the desired tag/commit hash and then run from the project root:
@@ -122,7 +152,7 @@ docker build . -t tag-for-playground-image
122152
Build args can be specified for the build-time environment variables:
123153

124154
```
125-
docker build --build-arg VITE_AUTHZED_DEVELOPER_GATEWAY_ENDPOINT=https://my.developer.endpoint . -t tag-for-playground-image
155+
docker build --build-arg VITE_SHARE_API_ENDPOINT=https://my.playground.endpoint . -t tag-for-playground-image
126156
```
127157

128158
## Developing your own schema

api/lookupshare.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
2+
import type { VercelRequest, VercelResponse } from "@vercel/node";
3+
4+
const encodeURL =
5+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
6+
7+
export default async function handler(req: VercelRequest, res: VercelResponse) {
8+
const shareid = req.query.shareid ?? "";
9+
if (typeof shareid !== "string") {
10+
return res.status(400).json({ error: "Invalid shareid" });
11+
}
12+
13+
if (!shareid) {
14+
return res.status(400).json({ error: "Share ID is required" });
15+
}
16+
17+
// Validate shareid contains only allowed characters
18+
for (const char of shareid) {
19+
if (!encodeURL.includes(char)) {
20+
return res.status(400).json({ error: "Invalid characters in share ID" });
21+
}
22+
}
23+
24+
// Check for required environment variables
25+
const s3Endpoint = process.env.S3_ENDPOINT;
26+
const s3Bucket = process.env.S3_BUCKET;
27+
if (!s3Endpoint || !s3Bucket) {
28+
console.warn("S3_ENDPOINT or S3_BUCKET environment variables are not set");
29+
return res.status(404).json({ error: "Share not found" });
30+
}
31+
32+
try {
33+
// Initialize S3 client
34+
const s3Client = new S3Client({
35+
endpoint: s3Endpoint,
36+
region: "auto",
37+
});
38+
39+
// Get object from S3
40+
const command = new GetObjectCommand({
41+
Bucket: s3Bucket,
42+
Key: `shared/${shareid}`,
43+
});
44+
45+
const response = await s3Client.send(command);
46+
if (!response.Body) {
47+
return res.status(404).json({ error: "Share not found" });
48+
}
49+
50+
// Read the stream and convert to string
51+
const bodyContents = await response.Body.transformToString();
52+
53+
// Parse JSON
54+
let shareData;
55+
try {
56+
shareData = JSON.parse(bodyContents);
57+
} catch (error) {
58+
console.error("Error parsing JSON for share store:", shareid, error);
59+
return res.status(400).json({ error: "Invalid JSON in share data" });
60+
}
61+
62+
// Validate version
63+
if (shareData.version !== "2") {
64+
return res.status(400).json({ error: "Older version is unsupported" });
65+
}
66+
67+
// Validate required string keys if present
68+
const requiredStringKeys = [
69+
"schema",
70+
"relationships_yaml",
71+
"validation_yaml",
72+
"assertions_yaml",
73+
];
74+
for (const key of requiredStringKeys) {
75+
if (key in shareData && typeof shareData[key] !== "string") {
76+
return res.status(400).json({ error: "Share data is not supported" });
77+
}
78+
}
79+
80+
return res.status(200).send(bodyContents);
81+
} catch (error) {
82+
if (error instanceof Error && error.name === "NoSuchKey") {
83+
return res.status(404).json({ error: "Share not found" });
84+
}
85+
console.error("Error retrieving share data:", error);
86+
return res.status(500).json({ error: "Internal server error" });
87+
}
88+
}

api/share.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2+
import type { VercelRequest, VercelResponse } from "@vercel/node";
3+
import { createHash } from "crypto";
4+
5+
const encodeURL =
6+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
7+
8+
export type SharedDataV2 = {
9+
version: "2";
10+
schema: string;
11+
relationships_yaml?: string;
12+
validation_yaml?: string;
13+
assertions_yaml?: string;
14+
};
15+
16+
const hashPrefixSize = 12;
17+
18+
function computeShareHash(salt: string, data: string): string {
19+
const hash = createHash("sha256");
20+
hash.update(salt + ":", "utf8");
21+
hash.update(data, "utf8");
22+
23+
const sum = hash.digest();
24+
const b64 = sum.toString("base64url");
25+
26+
let hashLen = hashPrefixSize;
27+
while (hashLen <= b64.length && b64[hashLen - 1] === "_") {
28+
hashLen++;
29+
}
30+
31+
return b64.substring(0, hashLen);
32+
}
33+
34+
function validateSharedDataV2(data): data is SharedDataV2 {
35+
if (typeof data !== "object" || data === null) {
36+
return false;
37+
}
38+
39+
if (data.version !== "2") {
40+
return false;
41+
}
42+
43+
if (typeof data.schema !== "string") {
44+
return false;
45+
}
46+
47+
const optionalStringFields = [
48+
"relationships_yaml",
49+
"validation_yaml",
50+
"assertions_yaml",
51+
];
52+
for (const field of optionalStringFields) {
53+
if (field in data && typeof data[field] !== "string") {
54+
return false;
55+
}
56+
}
57+
58+
return true;
59+
}
60+
61+
export default async function handler(req: VercelRequest, res: VercelResponse) {
62+
if (req.method !== "POST") {
63+
return res.status(405).json({ error: "Method not allowed" });
64+
}
65+
66+
// Check for required environment variables
67+
const s3Endpoint = process.env.S3_ENDPOINT;
68+
const s3Bucket = process.env.S3_BUCKET;
69+
const shareSalt = process.env.SHARE_SALT;
70+
71+
if (!s3Endpoint || !s3Bucket) {
72+
console.error("S3_ENDPOINT or S3_BUCKET environment variables are not set");
73+
return res.status(500).json({ error: "Server configuration error" });
74+
}
75+
76+
if (!shareSalt) {
77+
console.error("SHARE_SALT environment variable is not set");
78+
return res.status(500).json({ error: "Server configuration error" });
79+
}
80+
81+
// Validate request body
82+
const body = req.body;
83+
if (!body || typeof body !== "object") {
84+
console.error("Invalid request body:", body);
85+
return res.status(400).json({ error: "Invalid request body" });
86+
}
87+
88+
try {
89+
if (!validateSharedDataV2(body)) {
90+
return res.status(400).json({ error: "Invalid share data format" });
91+
}
92+
} catch {
93+
return res.status(400).json({ error: "Invalid JSON" });
94+
}
95+
96+
// Compute the share hash
97+
const dataString = JSON.stringify(body);
98+
const shareHash = computeShareHash(shareSalt, dataString);
99+
100+
// Validate that the computed hash only contains allowed characters
101+
for (const char of shareHash) {
102+
if (!encodeURL.includes(char)) {
103+
console.error(
104+
"Computed hash contains invalid character:",
105+
char,
106+
"in hash:",
107+
shareHash,
108+
);
109+
return res.status(500).json({ error: "Hash generation error" });
110+
}
111+
}
112+
113+
try {
114+
// Initialize S3 client
115+
const s3Client = new S3Client({
116+
endpoint: s3Endpoint,
117+
region: "auto",
118+
});
119+
120+
// Store in S3
121+
const command = new PutObjectCommand({
122+
Bucket: s3Bucket,
123+
Key: `shared/${shareHash}`,
124+
Body: dataString,
125+
ContentType: "application/json",
126+
});
127+
128+
await s3Client.send(command);
129+
130+
return res.status(200).json({ hash: shareHash });
131+
} catch (error) {
132+
console.error("Error storing share data:", error);
133+
return res.status(500).json({ error: "Failed to store share data" });
134+
}
135+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"private": true,
66
"dependencies": {
77
"@apollo/client": "^3.7.3",
8+
"@aws-sdk/client-s3": "^3.0.0",
89
"@apollo/link-context": "^2.0.0-beta.3",
910
"@bufbuild/protobuf": "^2.4.0",
1011
"@connectrpc/connect": "^2.0.2",
@@ -26,6 +27,7 @@
2627
"@tailwindcss/vite": "^4.1.5",
2728
"@tanstack/react-router": "^1.119.0",
2829
"@tanstack/react-router-devtools": "^1.119.1",
30+
"@vercel/node": "^5.2.0",
2931
"ansi-to-html": "^0.7.2",
3032
"class-variance-authority": "^0.7.1",
3133
"clsx": "^2.1.1",

src/components/EmbeddedPlayground.tsx

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ import {
1010
CheckOperationsResult_Membership,
1111
CheckOperationsResultSchema,
1212
} from "../spicedb-common/protodefs/developer/v1/developer_pb";
13-
import { DeveloperService } from "../spicedb-common/protodefs/authzed/api/v0/developer_pb";
14-
import { createGrpcWebTransport } from "@connectrpc/connect-web";
15-
import { createClient, ConnectError } from "@connectrpc/connect";
1613
import { useDeveloperService } from "../spicedb-common/services/developerservice";
1714
import {
1815
faCaretDown,
@@ -279,18 +276,11 @@ function EmbeddedPlaygroundUI(props: { datastore: DataStore }) {
279276
const { showAlert } = useAlert();
280277

281278
const shareAndOpen = async () => {
282-
const developerEndpoint = AppConfig().authzed?.developerEndpoint;
283-
if (!developerEndpoint) {
279+
const shareApiEndpoint = AppConfig().shareApiEndpoint;
280+
if (!shareApiEndpoint) {
284281
return;
285282
}
286283

287-
const client = createClient(
288-
DeveloperService,
289-
createGrpcWebTransport({
290-
baseUrl: developerEndpoint,
291-
}),
292-
);
293-
294284
const schema = datastore.getSingletonByKind(
295285
DataStoreItemKind.SCHEMA,
296286
).editableContents!;
@@ -306,23 +296,43 @@ function EmbeddedPlaygroundUI(props: { datastore: DataStore }) {
306296

307297
// Invoke sharing.
308298
try {
309-
const response = await client.share({
310-
schema,
311-
relationshipsYaml,
312-
assertionsYaml,
313-
validationYaml,
299+
const response = await fetch(`${shareApiEndpoint}/api/share`, {
300+
method: "POST",
301+
headers: {
302+
"Content-Type": "application/json",
303+
},
304+
body: JSON.stringify({
305+
version: "2",
306+
schema,
307+
relationships_yaml: relationshipsYaml,
308+
assertions_yaml: assertionsYaml,
309+
validation_yaml: validationYaml,
310+
}),
314311
});
315-
const reference = response.shareReference;
316-
window.open(`${window.location.origin}/s/${reference}`);
317-
} catch (error: unknown) {
318-
if (error instanceof ConnectError) {
312+
313+
if (!response.ok) {
314+
const errorData = await response
315+
.json()
316+
.catch(() => ({ error: "Unknown error" }));
319317
showAlert({
320318
title: "Error sharing",
321-
content: error.message,
319+
content: errorData.error || "Failed to share playground",
322320
buttonTitle: "Okay",
323321
});
324322
return;
325323
}
324+
325+
const result = await response.json();
326+
const reference = result.hash;
327+
window.open(`${window.location.origin}/s/${reference}`);
328+
} catch (error: unknown) {
329+
showAlert({
330+
title: "Error sharing",
331+
content:
332+
error instanceof Error ? error.message : "Failed to share playground",
333+
buttonTitle: "Okay",
334+
});
335+
return;
326336
}
327337
};
328338

0 commit comments

Comments
 (0)