Skip to content

Commit c730bcd

Browse files
Merge pull request #9 from leap0-dev/feature/presigned-urls
Add presigned URL APIs
2 parents 16065ba + f83b031 commit c730bcd

11 files changed

Lines changed: 184 additions & 19 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,18 @@ const access = await sandbox.ssh.createAccess();
132132
console.log(access.hostname, access.port, access.username);
133133
```
134134

135+
### Presigned URLs
136+
137+
Create a temporary public URL for a sandbox port. The optional second argument to
138+
`createPresignedUrl(port, expiresIn)` is `expiresIn` in seconds.
139+
140+
```ts
141+
const presigned = await sandbox.createPresignedUrl(8080, 900); // 15 minutes
142+
console.log(presigned.url);
143+
144+
await sandbox.deletePresignedUrl(presigned.id);
145+
```
146+
135147
### Desktop Automation
136148

137149
Control a graphical desktop inside the sandbox.

examples/ssh.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ async function main(): Promise<void> {
1111

1212
const validation = await sandbox.ssh.validateAccess(access.id, access.password);
1313
console.log("ssh valid:", validation.valid);
14+
15+
const rotated = await sandbox.ssh.regenerateAccess(access.id);
16+
console.log("rotated ssh command:", rotated.sshCommand);
1417
} finally {
1518
await sandbox.delete();
1619
}

src/client/sandbox.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { SandboxData, SandboxState } from "@/models/index.js";
1+
import type { PresignedUrl, SandboxData, SandboxState } from "@/models/index.js";
22
import { sandboxStateSchema } from "@/models/sandbox.js";
33
import {
44
CodeInterpreterClient,
@@ -209,6 +209,24 @@ export class Sandbox implements SandboxData {
209209
await this.client.sandboxes.delete(this.id, options);
210210
}
211211

212+
/**
213+
* Creates a temporary public URL for a specific sandbox port.
214+
*/
215+
async createPresignedUrl(
216+
port: number,
217+
expiresIn?: number,
218+
options?: { timeout?: number },
219+
): Promise<PresignedUrl> {
220+
return this.client.sandboxes.createPresignedUrl(this.id, { port, expiresIn }, options);
221+
}
222+
223+
/**
224+
* Deletes a previously issued presigned URL.
225+
*/
226+
async deletePresignedUrl(id: string, options?: { timeout?: number }): Promise<void> {
227+
await this.client.sandboxes.deletePresignedUrl(this.id, id, options);
228+
}
229+
212230
/**
213231
* Returns the public invoke URL for the sandbox.
214232
*

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ export {
1616

1717
export { Leap0Client, Sandbox } from "@/client/index.js";
1818
export {
19+
createPresignedUrlParamsSchema,
1920
CodeLanguage,
2021
codeLanguageSchema,
2122
listSandboxesParamsSchema,
2223
listSandboxesResponseSchema,
2324
listSnapshotsParamsSchema,
2425
listSnapshotsResponseSchema,
2526
NetworkPolicyMode,
27+
presignedUrlSchema,
2628
RegistryCredentialType,
2729
SandboxState,
2830
StreamEventType,
@@ -43,6 +45,7 @@ export {
4345
} from "@/services/index.js";
4446

4547
export type {
48+
CreatePresignedUrlParams,
4649
CodeContext,
4750
CodeExecutionError,
4851
CodeExecutionOutput,
@@ -80,6 +83,7 @@ export type {
8083
LsResult,
8184
NetworkPolicy,
8285
ProcessResult,
86+
PresignedUrl,
8387
PtySession,
8488
ResumeSnapshotParams,
8589
SandboxListItem,

src/models/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@ export type { RequestOptions } from "@/models/shared.js";
33
export type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/refs.js";
44

55
export {
6+
createPresignedUrlParamsSchema,
67
createSandboxParamsSchema,
78
listSandboxesParamsSchema,
89
listSandboxesResponseSchema,
910
NetworkPolicyMode,
11+
presignedUrlSchema,
1012
SandboxState,
1113
} from "@/models/sandbox.js";
1214
export type {
15+
CreatePresignedUrlParams,
1316
CreateSandboxParams,
1417
ListSandboxesParams,
1518
ListSandboxesResponse,
1619
NetworkPolicy,
20+
PresignedUrl,
1721
SandboxData,
1822
SandboxListItem,
1923
} from "@/models/sandbox.js";

src/models/sandbox.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,25 @@ export const listSandboxesResponseSchema = z
158158
/** Paginated sandbox list response. */
159159
export type ListSandboxesResponse = z.infer<typeof listSandboxesResponseSchema>;
160160

161+
export const createPresignedUrlParamsSchema = z.object({
162+
port: z.number().int().min(1).max(65535),
163+
expiresIn: z.number().int().min(1).optional(),
164+
});
165+
export type CreatePresignedUrlParams = z.infer<typeof createPresignedUrlParamsSchema>;
166+
167+
export const presignedUrlSchema = z
168+
.object({
169+
id: z.string(),
170+
token: z.string(),
171+
url: z.string().url(),
172+
sandboxId: z.string(),
173+
port: z.number().int().min(1).max(65535),
174+
expiresAt: z.string(),
175+
createdAt: z.string(),
176+
})
177+
.catchall(z.unknown());
178+
export type PresignedUrl = z.infer<typeof presignedUrlSchema>;
179+
161180
export const listSandboxesParamsSchema = z
162181
.object({
163182
state: z

src/services/sandboxes.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@ import { OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS } from "@/confi
22
import { Leap0Error } from "@/core/errors.js";
33
import { normalize } from "@/core/normalize.js";
44
import {
5+
createPresignedUrlParamsSchema,
56
createSandboxRuntimeParamsSchema,
67
listSandboxesParamsSchema,
78
listSandboxesResponseSchema,
9+
presignedUrlSchema,
810
sandboxDataSchema,
911
toNetworkPolicyWire,
1012
} from "@/models/sandbox.js";
1113
import type {
14+
CreatePresignedUrlParams,
1215
CreateSandboxParams,
1316
ListSandboxesParams,
1417
ListSandboxesResponse,
18+
PresignedUrl,
1519
RequestOptions,
1620
SandboxData,
1721
SandboxRef,
@@ -252,6 +256,52 @@ export class SandboxesClient<T = SandboxData> {
252256
});
253257
}
254258

259+
/**
260+
* Creates a temporary public URL for a specific sandbox port.
261+
*/
262+
async createPresignedUrl(
263+
sandbox: SandboxRef,
264+
params: CreatePresignedUrlParams,
265+
options: RequestOptions = {},
266+
): Promise<PresignedUrl> {
267+
const parsed = createPresignedUrlParamsSchema.parse(params);
268+
return withErrorPrefix("Failed to create presigned URL: ", async () => {
269+
const data = await this.transport.requestJson<unknown>(
270+
`/v1/sandbox/${sandboxIdOf(sandbox)}/presigned-url`,
271+
{
272+
method: "POST",
273+
body: jsonBody({
274+
port: parsed.port,
275+
expires_in: parsed.expiresIn,
276+
}),
277+
},
278+
options,
279+
);
280+
return normalize(presignedUrlSchema, data);
281+
});
282+
}
283+
284+
/**
285+
* Deletes a previously issued presigned URL.
286+
*/
287+
async deletePresignedUrl(
288+
sandbox: SandboxRef,
289+
id: string,
290+
options: RequestOptions = {},
291+
): Promise<void> {
292+
const trimmedID = id.trim();
293+
if (!trimmedID) {
294+
throw new Leap0Error("id must be a non-empty string");
295+
}
296+
await withErrorPrefix("Failed to delete presigned URL: ", () =>
297+
this.transport.request(
298+
`/v1/sandbox/${sandboxIdOf(sandbox)}/presigned-url/${trimmedID}`,
299+
{ method: "DELETE" },
300+
options,
301+
),
302+
);
303+
}
304+
255305

256306
/**
257307
* Builds the public invoke URL for a sandbox.

src/services/ssh.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,52 +29,54 @@ export class SshClient {
2929
}
3030

3131
/**
32-
* Deletes the active SSH credentials for a sandbox.
32+
* Deletes a specific SSH credential for a sandbox.
3333
*
3434
* @param sandbox Sandbox ID or sandbox-like object.
35+
* @param id The SSH credential ID to delete.
3536
* @param options Optional request settings such as timeout and query params.
3637
*/
37-
async deleteAccess(sandbox: SandboxRef, options: RequestOptions = {}): Promise<void> {
38+
async deleteAccess(sandbox: SandboxRef, id: string, options: RequestOptions = {}): Promise<void> {
3839
await this.transport.request(
39-
`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/access`,
40+
`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/${id}`,
4041
{ method: "DELETE" },
4142
options,
4243
);
4344
}
4445

4546
/**
46-
* Verifies a previously issued SSH credential pair.
47+
* Verifies a specific previously issued SSH credential pair.
4748
*
4849
* @param sandbox Sandbox ID or sandbox-like object.
49-
* @param accessId The SSH access ID to validate.
50+
* @param id The SSH credential ID to validate.
5051
* @param password The password returned when the access credential was created.
5152
* @param options Optional request settings such as timeout and query params.
5253
* @returns The validation result.
5354
*/
5455
async validateAccess(
5556
sandbox: SandboxRef,
56-
accessId: string,
57+
id: string,
5758
password: string,
5859
options: RequestOptions = {},
5960
): Promise<SshValidation> {
6061
const data = await this.transport.requestJson<SshValidation>(
61-
`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/validate`,
62-
{ method: "POST", body: jsonBody({ id: accessId, password }) },
62+
`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/${id}/validate`,
63+
{ method: "POST", body: jsonBody({ password }) },
6364
options,
6465
);
6566
return normalize(sshValidationSchema, data);
6667
}
6768

6869
/**
69-
* Rotates SSH credentials for a sandbox.
70+
* Rotates a specific SSH credential for a sandbox.
7071
*
7172
* @param sandbox Sandbox ID or sandbox-like object.
73+
* @param id The SSH credential ID to rotate.
7274
* @param options Optional request settings such as timeout and query params.
7375
* @returns The newly generated SSH access payload.
7476
*/
75-
async regenerateAccess(sandbox: SandboxRef, options: RequestOptions = {}): Promise<SshAccess> {
77+
async regenerateAccess(sandbox: SandboxRef, id: string, options: RequestOptions = {}): Promise<SshAccess> {
7678
const data = await this.transport.requestJson<SshAccess>(
77-
`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/regen`,
79+
`/v1/sandbox/${sandboxIdOf(sandbox)}/ssh/${id}/regen`,
7880
{ method: "POST" },
7981
options,
8082
);

tests/client/client-sandbox.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { expectTypeOf, test } from "vitest";
33

44
import { Leap0Client, Sandbox } from "@/client/index.js";
55
import { SERVICES } from "@/client/sandbox.js";
6-
import type { RequestOptions } from "@/models/index.js";
6+
import type { PresignedUrl, RequestOptions } from "@/models/index.js";
77

88
test("Leap0Client wires services and supports direct access", async () => {
99
const originalApiKey = process.env.LEAP0_API_KEY;
@@ -86,6 +86,16 @@ test("Sandbox binds service methods to itself", async () => {
8686
createdAt: "2026-01-01T00:00:00Z",
8787
}),
8888
delete: async () => undefined,
89+
createPresignedUrl: async () => ({
90+
id: "psu-1",
91+
token: "tok_1",
92+
url: "https://tok_1.leap0.app",
93+
sandboxId: "sb-1",
94+
port: 8080,
95+
expiresAt: "2026-01-01T00:15:00Z",
96+
createdAt: "2026-01-01T00:00:00Z",
97+
}),
98+
deletePresignedUrl: async () => undefined,
8999
getUserHomeDir: async (id: string) => `home:${id}`,
90100
getWorkdir: async (id: string) => `workdir:${id}`,
91101
invokeUrl: (id: string, path: string, port?: number) => `invoke:${id}:${path}:${port ?? ""}`,
@@ -124,6 +134,8 @@ test("Sandbox binds service methods to itself", async () => {
124134
assert.equal(sandbox.invokeUrl("/healthz", 3000), "invoke:sb-1:/healthz:3000");
125135
assert.equal(await sandbox.getUserHomeDir(), "home:sb-1");
126136
assert.equal(await sandbox.getWorkdir(), "workdir:sb-1");
137+
assert.equal((await sandbox.createPresignedUrl(8080, 15)).url, "https://tok_1.leap0.app");
138+
await sandbox.deletePresignedUrl("psu-1");
127139
});
128140

129141
test("Sandbox refresh rejects invalid sandbox states", async () => {
@@ -180,10 +192,17 @@ test("client and sandbox helpers stay strongly typed", () => {
180192
[params: { command: string; cwd?: string; timeout?: number; env?: Record<string, string> }, options?: RequestOptions]
181193
>();
182194
expectTypeOf<Sandbox["ssh"]["validateAccess"]>().parameters.toEqualTypeOf<
183-
[accessId: string, password: string, options?: RequestOptions]
195+
[id: string, password: string, options?: RequestOptions]
196+
>();
197+
expectTypeOf<Sandbox["ssh"]["deleteAccess"]>().parameters.toEqualTypeOf<
198+
[id: string, options?: RequestOptions]
199+
>();
200+
expectTypeOf<Sandbox["ssh"]["regenerateAccess"]>().parameters.toEqualTypeOf<
201+
[id: string, options?: RequestOptions]
184202
>();
185203
expectTypeOf<ReturnType<Sandbox["getUserHomeDir"]>>().toEqualTypeOf<Promise<string>>();
186204
expectTypeOf<ReturnType<Sandbox["getWorkdir"]>>().toEqualTypeOf<Promise<string>>();
205+
expectTypeOf<ReturnType<Sandbox["createPresignedUrl"]>>().toEqualTypeOf<Promise<PresignedUrl>>();
187206
expectTypeOf<Sandbox["templateName"]>().toEqualTypeOf<string | undefined>();
188207
expectTypeOf<Sandbox["timeoutMin"]>().toEqualTypeOf<number | undefined>();
189208
expectTypeOf<Sandbox["envVars"]>().toEqualTypeOf<Record<string, string> | undefined>();

tests/services/sandboxes-client.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,39 @@ test("sandboxes runtime info rejects non-object responses", async () => {
115115
await assert.rejects(() => client.getWorkdir("sb-1"), /missing workdir/);
116116
});
117117

118+
test("sandboxes create and delete presigned urls", async () => {
119+
const { transport, calls } = createRecordedTransport({
120+
requestJson: (path: string, init: RequestInit, options: unknown) => {
121+
calls.push({ path, init, options: options as never });
122+
return Promise.resolve({
123+
id: "psu-1",
124+
token: "tok_1",
125+
url: "https://tok_1.leap0.app",
126+
sandbox_id: "sb-1",
127+
port: 8080,
128+
expires_at: "2026-01-01T00:15:00Z",
129+
created_at: "2026-01-01T00:00:00Z",
130+
});
131+
},
132+
request: (path: string, init: RequestInit, options: unknown) => {
133+
calls.push({ path, init, options: options as never });
134+
return Promise.resolve(new Response(null, { status: 204 }));
135+
},
136+
});
137+
const client = new SandboxesClient(transport as never);
138+
139+
const created = await client.createPresignedUrl("sb-1", { port: 8080, expiresIn: 900 });
140+
await client.deletePresignedUrl("sb-1", created.id);
141+
142+
assert.equal(created.url, "https://tok_1.leap0.app");
143+
assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/presigned-url");
144+
assert.deepEqual(jsonOf(calls[0]!) as { port: number; expires_in: number }, {
145+
port: 8080,
146+
expires_in: 900,
147+
});
148+
assert.equal(calls[1]?.path, "/v1/sandbox/sb-1/presigned-url/psu-1");
149+
});
150+
118151

119152
test("sandboxes list sends query params and normalizes response", async () => {
120153
const { transport, calls } = createRecordedTransport({

0 commit comments

Comments
 (0)