Skip to content

Commit ac850e8

Browse files
committed
fix(ci): replace tlon git api dependency
1 parent 2884ac1 commit ac850e8

7 files changed

Lines changed: 341 additions & 188 deletions

File tree

extensions/tlon/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"description": "OpenClaw Tlon/Urbit channel plugin",
55
"type": "module",
66
"dependencies": {
7-
"@tloncorp/api": "git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87",
7+
"@aws-sdk/client-s3": "3.1000.0",
8+
"@aws-sdk/s3-request-presigner": "3.1000.0",
89
"@tloncorp/tlon-skill": "0.2.2",
910
"@urbit/aura": "^3.0.0",
1011
"zod": "^4.3.6"

extensions/tlon/src/channel.runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import crypto from "node:crypto";
2-
import { configureClient } from "@tloncorp/api";
32
import type {
43
ChannelAccountSnapshot,
54
ChannelOutboundAdapter,
@@ -15,6 +14,7 @@ import {
1514
parseTlonTarget,
1615
resolveTlonOutboundTarget,
1716
} from "./targets.js";
17+
import { configureClient } from "./tlon-api.js";
1818
import { resolveTlonAccount } from "./types.js";
1919
import { authenticate } from "./urbit/auth.js";
2020
import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js";
@@ -169,6 +169,7 @@ export const tlonRuntimeOutbound: ChannelOutboundAdapter = {
169169
shipName: account.ship.replace(/^~/, ""),
170170
verbose: false,
171171
getCode: async () => account.code,
172+
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined,
172173
});
173174

174175
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;

extensions/tlon/src/tlon-api.ts

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import crypto from "node:crypto";
2+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
3+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
4+
import { authenticate } from "./urbit/auth.js";
5+
import { scryUrbitPath } from "./urbit/channel-ops.js";
6+
import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js";
7+
8+
type ClientConfig = {
9+
shipUrl: string;
10+
shipName: string;
11+
verbose: boolean;
12+
getCode: () => Promise<string>;
13+
allowPrivateNetwork?: boolean;
14+
};
15+
16+
type StorageService = "presigned-url" | "credentials";
17+
18+
type StorageConfiguration = {
19+
buckets: string[];
20+
currentBucket: string;
21+
region: string;
22+
publicUrlBase: string;
23+
presignedUrl: string;
24+
service: StorageService;
25+
};
26+
27+
type StorageCredentials = {
28+
endpoint: string;
29+
accessKeyId: string;
30+
secretAccessKey: string;
31+
};
32+
33+
type UploadFileParams = {
34+
blob: Blob;
35+
fileName?: string;
36+
contentType?: string;
37+
};
38+
39+
type UploadResult = {
40+
url: string;
41+
};
42+
43+
const MEMEX_BASE_URL = "https://memex.tlon.network";
44+
45+
const mimeToExt: Record<string, string> = {
46+
"image/gif": ".gif",
47+
"image/heic": ".heic",
48+
"image/heif": ".heif",
49+
"image/jpeg": ".jpg",
50+
"image/jpg": ".jpg",
51+
"image/png": ".png",
52+
"image/webp": ".webp",
53+
};
54+
55+
let currentClientConfig: ClientConfig | null = null;
56+
57+
export function configureClient(params: ClientConfig): void {
58+
currentClientConfig = {
59+
...params,
60+
shipName: params.shipName.replace(/^~/, ""),
61+
};
62+
}
63+
64+
function requireClientConfig(): ClientConfig {
65+
if (!currentClientConfig) {
66+
throw new Error("Tlon client not configured");
67+
}
68+
return currentClientConfig;
69+
}
70+
71+
function getExtensionFromMimeType(mimeType?: string): string {
72+
if (!mimeType) {
73+
return ".jpg";
74+
}
75+
return mimeToExt[mimeType.toLowerCase()] || ".jpg";
76+
}
77+
78+
function hasCustomS3Creds(
79+
credentials: StorageCredentials | null,
80+
): credentials is StorageCredentials {
81+
return Boolean(credentials?.accessKeyId && credentials?.endpoint && credentials?.secretAccessKey);
82+
}
83+
84+
function isStorageCredentials(value: unknown): value is StorageCredentials {
85+
if (!value || typeof value !== "object") {
86+
return false;
87+
}
88+
const record = value as Record<string, unknown>;
89+
return (
90+
typeof record.endpoint === "string" &&
91+
typeof record.accessKeyId === "string" &&
92+
typeof record.secretAccessKey === "string"
93+
);
94+
}
95+
96+
function isHostedShipUrl(shipUrl: string): boolean {
97+
try {
98+
const { hostname } = new URL(shipUrl);
99+
return hostname.endsWith("tlon.network") || hostname.endsWith(".test.tlon.systems");
100+
} catch {
101+
return shipUrl.endsWith("tlon.network") || shipUrl.endsWith(".test.tlon.systems");
102+
}
103+
}
104+
105+
function prefixEndpoint(endpoint: string): string {
106+
return endpoint.match(/https?:\/\//) ? endpoint : `https://${endpoint}`;
107+
}
108+
109+
function sanitizeFileName(fileName: string): string {
110+
return fileName.split(/[/\\]/).pop() || fileName;
111+
}
112+
113+
async function getAuthCookie(config: ClientConfig): Promise<string> {
114+
return await authenticate(config.shipUrl, await config.getCode(), {
115+
ssrfPolicy: ssrfPolicyFromAllowPrivateNetwork(config.allowPrivateNetwork),
116+
});
117+
}
118+
119+
async function scryJson<T>(config: ClientConfig, cookie: string, path: string): Promise<T> {
120+
return (await scryUrbitPath(
121+
{
122+
baseUrl: config.shipUrl,
123+
cookie,
124+
ssrfPolicy: ssrfPolicyFromAllowPrivateNetwork(config.allowPrivateNetwork),
125+
},
126+
{ path, auditContext: "tlon-storage-scry" },
127+
)) as T;
128+
}
129+
130+
async function getStorageConfiguration(
131+
config: ClientConfig,
132+
cookie: string,
133+
): Promise<StorageConfiguration> {
134+
const result = await scryJson<
135+
{ "storage-update"?: { configuration?: StorageConfiguration } } | StorageConfiguration
136+
>(config, cookie, "/storage/configuration.json");
137+
138+
if ("storage-update" in result && result["storage-update"]?.configuration) {
139+
return result["storage-update"].configuration;
140+
}
141+
if ("currentBucket" in result) {
142+
return result;
143+
}
144+
throw new Error("Invalid storage configuration response");
145+
}
146+
147+
async function getStorageCredentials(
148+
config: ClientConfig,
149+
cookie: string,
150+
): Promise<StorageCredentials | null> {
151+
const result = await scryJson<
152+
{ "storage-update"?: { credentials?: StorageCredentials } } | StorageCredentials
153+
>(config, cookie, "/storage/credentials.json");
154+
155+
if ("storage-update" in result) {
156+
return result["storage-update"]?.credentials ?? null;
157+
}
158+
if (isStorageCredentials(result)) {
159+
return result;
160+
}
161+
return null;
162+
}
163+
164+
async function getMemexUploadUrl(params: {
165+
config: ClientConfig;
166+
cookie: string;
167+
contentLength: number;
168+
contentType: string;
169+
fileName: string;
170+
}): Promise<{ hostedUrl: string; uploadUrl: string }> {
171+
const token = await scryJson<string | { secret?: string }>(
172+
params.config,
173+
params.cookie,
174+
"/genuine/secret.json",
175+
);
176+
const resolvedToken = typeof token === "string" ? token : token.secret;
177+
if (!resolvedToken) {
178+
throw new Error("Missing genuine secret");
179+
}
180+
181+
const endpoint = `${MEMEX_BASE_URL}/v1/${params.config.shipName}/upload`;
182+
const response = await fetch(endpoint, {
183+
method: "PUT",
184+
headers: { "Content-Type": "application/json" },
185+
body: JSON.stringify({
186+
token: resolvedToken,
187+
contentLength: params.contentLength,
188+
contentType: params.contentType,
189+
fileName: params.fileName,
190+
}),
191+
});
192+
193+
if (!response.ok) {
194+
throw new Error(`Memex upload request failed: ${response.status}`);
195+
}
196+
197+
const data = (await response.json()) as { url?: string; filePath?: string } | null;
198+
if (!data?.url || !data.filePath) {
199+
throw new Error("Invalid response from Memex");
200+
}
201+
202+
return { hostedUrl: data.filePath, uploadUrl: data.url };
203+
}
204+
205+
export async function uploadFile(params: UploadFileParams): Promise<UploadResult> {
206+
const config = requireClientConfig();
207+
const cookie = await getAuthCookie(config);
208+
209+
const [storageConfig, credentials] = await Promise.all([
210+
getStorageConfiguration(config, cookie),
211+
getStorageCredentials(config, cookie),
212+
]);
213+
214+
const contentType = params.contentType || params.blob.type || "application/octet-stream";
215+
const extension = getExtensionFromMimeType(contentType);
216+
const fileName = sanitizeFileName(params.fileName || `upload${extension}`);
217+
const fileKey = `${config.shipName}/${Date.now()}-${crypto.randomUUID()}-${fileName}`;
218+
219+
const useMemex =
220+
isHostedShipUrl(config.shipUrl) &&
221+
(storageConfig.service === "presigned-url" || !hasCustomS3Creds(credentials));
222+
223+
if (useMemex) {
224+
const { hostedUrl, uploadUrl } = await getMemexUploadUrl({
225+
config,
226+
cookie,
227+
contentLength: params.blob.size,
228+
contentType,
229+
fileName: fileKey,
230+
});
231+
232+
const response = await fetch(uploadUrl, {
233+
method: "PUT",
234+
body: params.blob,
235+
headers: {
236+
"Cache-Control": "public, max-age=3600",
237+
"Content-Type": contentType,
238+
},
239+
});
240+
241+
if (!response.ok) {
242+
throw new Error(`Upload failed: ${response.status}`);
243+
}
244+
245+
return { url: hostedUrl };
246+
}
247+
248+
if (!hasCustomS3Creds(credentials)) {
249+
throw new Error("No storage credentials configured");
250+
}
251+
252+
const endpoint = new URL(prefixEndpoint(credentials.endpoint));
253+
const client = new S3Client({
254+
endpoint: {
255+
protocol: endpoint.protocol.slice(0, -1) as "http" | "https",
256+
hostname: endpoint.host,
257+
path: endpoint.pathname || "/",
258+
},
259+
region: storageConfig.region || "us-east-1",
260+
credentials: {
261+
accessKeyId: credentials.accessKeyId,
262+
secretAccessKey: credentials.secretAccessKey,
263+
},
264+
forcePathStyle: true,
265+
});
266+
267+
const headers: Record<string, string> = {
268+
"Cache-Control": "public, max-age=3600",
269+
"Content-Type": contentType,
270+
"x-amz-acl": "public-read",
271+
};
272+
273+
const command = new PutObjectCommand({
274+
Bucket: storageConfig.currentBucket,
275+
Key: fileKey,
276+
ContentType: headers["Content-Type"],
277+
CacheControl: headers["Cache-Control"],
278+
ACL: "public-read",
279+
});
280+
281+
const signedUrl = await getSignedUrl(client, command, {
282+
expiresIn: 3600,
283+
signableHeaders: new Set(Object.keys(headers)),
284+
});
285+
286+
const response = await fetch(signedUrl, {
287+
method: "PUT",
288+
body: params.blob,
289+
headers: signedUrl.includes("digitaloceanspaces.com") ? headers : undefined,
290+
});
291+
292+
if (!response.ok) {
293+
throw new Error(`Upload failed: ${response.status}`);
294+
}
295+
296+
const publicUrl = storageConfig.publicUrlBase
297+
? new URL(fileKey, storageConfig.publicUrlBase).toString()
298+
: signedUrl.split("?")[0];
299+
300+
return { url: publicUrl };
301+
}

extensions/tlon/src/tloncorp-api.d.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

extensions/tlon/src/urbit/upload.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
99
};
1010
});
1111

12-
// Mock @tloncorp/api
13-
vi.mock("@tloncorp/api", () => ({
12+
// Mock the local Tlon upload seam.
13+
vi.mock("../tlon-api.js", () => ({
1414
uploadFile: vi.fn(),
1515
}));
1616

1717
describe("uploadImageFromUrl", () => {
1818
async function loadUploadMocks() {
1919
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/infra-runtime");
20-
const { uploadFile } = await import("@tloncorp/api");
20+
const { uploadFile } = await import("../tlon-api.js");
2121
const { uploadImageFromUrl } = await import("./upload.js");
2222
return {
2323
mockFetch: vi.mocked(fetchWithSsrFGuard),

extensions/tlon/src/urbit/upload.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* Upload an image from a URL to Tlon storage.
33
*/
4-
import { uploadFile } from "@tloncorp/api";
54
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/infra-runtime";
5+
import { uploadFile } from "../tlon-api.js";
66
import { getDefaultSsrFPolicy } from "./context.js";
77

88
/**

0 commit comments

Comments
 (0)