@tealbrick/deliver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tealbrick contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ Independently authored for Teal Brick. Behavior reference: installed
2
+ @kybernesis/engineer 0.6.2 deliver tool and @kybernesis/exe preflight.
3
+ No donor implementation copied or imported. Uses official AWS and Vercel SDKs
4
+ for provider transport, shared Teal Brick bounded waits, and Eve 0.49.0 tools.
5
+ Dependencies retain their respective licenses.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # @tealbrick/deliver
2
+
3
+ Deliver sandbox files to chat recipients using a provider chosen during setup.
4
+ Node 24+, native Eve 0.55.0 tool adapter. Host-independent byte delivery API.
5
+
6
+ ## First-time setup
7
+
8
+ After installing this package in your Eve project, run:
9
+
10
+ ```sh
11
+ npx tealbrick-deliver setup
12
+ ```
13
+
14
+ 1. Select R2, AWS S3, custom S3-compatible storage (including self-hosted MinIO),
15
+ Vercel Blob, or a directory already served by your web server.
16
+ 2. Enter provider-specific endpoint/bucket/region and masked credentials.
17
+ 3. Select signed or public access where supported; explicitly accept public links.
18
+ 4. Optionally verify a disposable upload/download/delete before saving.
19
+
20
+ Creates `.tealbrick/deliver.json`, owner-only `.tealbrick/deliver.credentials.json`,
21
+ and `agent/tools/deliver.ts`. Adds `/.tealbrick/` to the project's `.gitignore` before
22
+ saving. Existing settings/tools are never overwritten. Config stores environment
23
+ variable names, not credentials. Local credential-file permissions are checked.
24
+ Windows hosts must additionally restrict access using their account ACLs.
25
+
26
+ Deploy settings separately from source. Supply referenced credentials as runtime
27
+ secrets, or explicitly provision the protected credentials file. Do not put secrets
28
+ in the agent mount, image, browser, sandbox or chat. Runtime working directory must
29
+ contain `.tealbrick/`, or pass absolute paths to `configuredDeliverTool`.
30
+
31
+ ```sh
32
+ npx tealbrick-deliver check
33
+ ```
34
+
35
+ `check` creates a disposable object, fetches its recipient-facing URL, compares the
36
+ bytes and deletes it. Failure is explicit. A failed cleanup returns only the object
37
+ key for operator reconciliation. This checks the host's network reachability;
38
+ mobile/desktop/Telegram acceptance must also be tested from those clients.
39
+ No buckets or web servers are provisioned by setup.
40
+
41
+ ## Providers
42
+
43
+ | Provider | Required target and credentials | Delivery |
44
+ |---|---|---|
45
+ | R2 | HTTPS S3 endpoint, bucket, key ID and secret; region `auto` | Signed (default), or configured public base URL |
46
+ | AWS S3 | Bucket, region, key ID and secret | Signed (default), or configured public base URL |
47
+ | S3-compatible / MinIO | HTTPS endpoint, bucket, region, addressing style, key pair | Signed or public base URL |
48
+ | Vercel Blob | Public-store token | Public URL |
49
+ | Served directory | Absolute operator-owned directory and HTTPS serving URL | Public URL |
50
+
51
+ Provider selection is explicit. `BLOB_READ_WRITE_TOKEN`, `DELIVER_DIR` and other
52
+ legacy variables do not silently select a provider. Custom endpoints require HTTPS;
53
+ self-hosted services can terminate TLS through a reverse proxy. Private tailnet
54
+ hosts are reachable only to recipients on that network.
55
+
56
+ Signed URLs default to 24 hours; configure 60 seconds through seven days. An object
57
+ can outlive its link. This package does not renew links or run a retention job.
58
+ Configure bucket lifecycle separately. R2 signed URLs use the S3 endpoint, not a
59
+ custom domain. Public URLs require an already configured public bucket/domain.
60
+ No bucket ACL changes are made. Temporary S3 credentials may expire before the
61
+ requested link lifetime; direct config supports `sessionTokenEnv`.
62
+
63
+ ## Eve tool
64
+
65
+ Setup creates this mount:
66
+
67
+ ```ts
68
+ import {configuredDeliverTool} from '@tealbrick/deliver/eve';
69
+ export default configuredDeliverTool();
70
+ ```
71
+
72
+ The agent calls `deliver({path:'/workspace/report.pdf',filename:'report.pdf'})`.
73
+ Only the current Eve sandbox is read. Mount this capability only on authorized
74
+ agents; the host owns session authorization. No local host-file read tool is exposed.
75
+
76
+ Result: `{url, objectKey, filename, contentType, byteSize, sha256, access, expiresAt?, note}`.
77
+ The chat client can display/open the URL. Native Telegram attachment uploads and
78
+ stable Teal Brick link renewal are future channel/gateway work, not implemented
79
+ by this package. Signed links are bearer capabilities; no Portal cookie is needed.
80
+ Treat them as sensitive when logging or forwarding.
81
+
82
+ ## Programmatic use
83
+
84
+ ```ts
85
+ import {createDeliver,validateDeliveryConfig} from '@tealbrick/deliver';
86
+ const config=validateDeliveryConfig({
87
+ version:1,provider:'r2',endpoint:'https://ACCOUNT.r2.cloudflarestorage.com',
88
+ bucket:'my-deliveries',region:'auto',forcePathStyle:true,
89
+ accessKeyIdEnv:'TEALBRICK_DELIVER_ACCESS_KEY_ID',
90
+ secretAccessKeyEnv:'TEALBRICK_DELIVER_SECRET_ACCESS_KEY',
91
+ access:'signed',expiresIn:86400,
92
+ });
93
+ const deliver=createDeliver(config);
94
+ const result=await deliver({data:new Uint8Array([1,2,3]),filename:'export.bin'});
95
+ ```
96
+
97
+ Defaults: 100 MiB per file, 30-second timeout (configurable up to 120 seconds).
98
+ The Eve sandbox API returns the whole file before the size check; this is not a
99
+ streaming upload implementation. Cancellation bounds waits; ambiguous failures
100
+ report the generated object key so an operator can reconcile whether an upload exists. Do not blindly retry them. Unique delivery keys
101
+ prevent overwrite; setup cleanup only addresses its own generated key.
102
+
103
+ Unknown formats, HTML and SVG use download MIME rather than active web content.
104
+ Serve directory files as downloads with `X-Content-Type-Options: nosniff` in your
105
+ web server. The directory and parents must be operator-owned, outside the sandbox;
106
+ symlinked child directories are rejected. Files must be readable by your web server.
107
+
108
+ This package is the file-delivery adapter, not Knowledge document ingestion.
109
+ Tests use temporary directories, injected SDK transports and local credentials.
110
+ Fresh tarball Eve discovery/build is distinct from live provider/client validation.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { select, input, password, confirm } from '@inquirer/prompts';
3
+ import { collectSetup, saveSetup, loadCredentialFile } from './setup.js';
4
+ import { loadDeliveryConfig, verifyDelivery } from './index.js';
5
+ import { mkdir } from 'node:fs/promises';
6
+ async function main() {
7
+ const command = process.argv[2];
8
+ if (command === 'setup') {
9
+ console.log('Teal Brick file delivery setup. Run in your Eve agent project. Local credentials are stored separately with owner-only permissions; deploy them as runtime secrets.');
10
+ const prompt = { select: (message, choices) => select({ message, choices }), input: (message, defaultValue) => input({ message, default: defaultValue }), password: (message) => password({ message, mask: '*' }), confirm: (message) => confirm({ message, default: false }) };
11
+ const result = await collectSetup(prompt);
12
+ if (await prompt.confirm('Upload, download and delete a disposable test file now?')) {
13
+ if (result.config.provider === 'directory')
14
+ await mkdir(result.config.directory, { recursive: true });
15
+ const check = await verifyDelivery(result.config, { env: result.credentials });
16
+ console.log(JSON.stringify(check));
17
+ if (!check.ok)
18
+ throw Error('delivery_verification_failed_settings_not_saved');
19
+ }
20
+ else
21
+ console.log('Delivery reachability is unverified. Run tealbrick-deliver check after configuring hosting.');
22
+ console.log(JSON.stringify(await saveSetup(process.cwd(), result.config, result.credentials)));
23
+ }
24
+ else if (command === 'check') {
25
+ const config = await loadDeliveryConfig('.tealbrick/deliver.json');
26
+ const env = { ...process.env, ...await loadCredentialFile('.tealbrick/deliver.credentials.json') };
27
+ const result = await verifyDelivery(config, { env });
28
+ console.log(JSON.stringify(result));
29
+ if (!result.ok)
30
+ process.exitCode = 1;
31
+ }
32
+ else {
33
+ console.log('Usage: tealbrick-deliver setup | check\nsetup: choose provider and mount the Eve deliver tool\ncheck: upload/read/delete a disposable object to verify the recipient URL');
34
+ if (command && command !== '--help')
35
+ process.exitCode = 1;
36
+ }
37
+ }
38
+ main().catch(() => { console.error('Delivery setup/check failed. Check settings, permissions and connectivity. Provider details and credentials are omitted.'); process.exitCode = 1; });
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ export declare const configSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
3
+ provider: z.ZodLiteral<"r2">;
4
+ region: z.ZodLiteral<"auto">;
5
+ endpoint: z.ZodString;
6
+ bucket: z.ZodString;
7
+ forcePathStyle: z.ZodDefault<z.ZodBoolean>;
8
+ accessKeyIdEnv: z.ZodString;
9
+ secretAccessKeyEnv: z.ZodString;
10
+ sessionTokenEnv: z.ZodOptional<z.ZodString>;
11
+ access: z.ZodDefault<z.ZodEnum<{
12
+ signed: "signed";
13
+ public: "public";
14
+ }>>;
15
+ expiresIn: z.ZodDefault<z.ZodNumber>;
16
+ publicBaseUrl: z.ZodOptional<z.ZodString>;
17
+ version: z.ZodLiteral<1>;
18
+ maxBytes: z.ZodDefault<z.ZodNumber>;
19
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
20
+ prefix: z.ZodDefault<z.ZodString>;
21
+ }, z.core.$strict>, z.ZodObject<{
22
+ provider: z.ZodLiteral<"s3">;
23
+ endpoint: z.ZodOptional<z.ZodString>;
24
+ bucket: z.ZodString;
25
+ region: z.ZodString;
26
+ forcePathStyle: z.ZodDefault<z.ZodBoolean>;
27
+ accessKeyIdEnv: z.ZodString;
28
+ secretAccessKeyEnv: z.ZodString;
29
+ sessionTokenEnv: z.ZodOptional<z.ZodString>;
30
+ access: z.ZodDefault<z.ZodEnum<{
31
+ signed: "signed";
32
+ public: "public";
33
+ }>>;
34
+ expiresIn: z.ZodDefault<z.ZodNumber>;
35
+ publicBaseUrl: z.ZodOptional<z.ZodString>;
36
+ version: z.ZodLiteral<1>;
37
+ maxBytes: z.ZodDefault<z.ZodNumber>;
38
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
39
+ prefix: z.ZodDefault<z.ZodString>;
40
+ }, z.core.$strict>, z.ZodObject<{
41
+ provider: z.ZodLiteral<"s3-compatible">;
42
+ endpoint: z.ZodString;
43
+ bucket: z.ZodString;
44
+ region: z.ZodString;
45
+ forcePathStyle: z.ZodDefault<z.ZodBoolean>;
46
+ accessKeyIdEnv: z.ZodString;
47
+ secretAccessKeyEnv: z.ZodString;
48
+ sessionTokenEnv: z.ZodOptional<z.ZodString>;
49
+ access: z.ZodDefault<z.ZodEnum<{
50
+ signed: "signed";
51
+ public: "public";
52
+ }>>;
53
+ expiresIn: z.ZodDefault<z.ZodNumber>;
54
+ publicBaseUrl: z.ZodOptional<z.ZodString>;
55
+ version: z.ZodLiteral<1>;
56
+ maxBytes: z.ZodDefault<z.ZodNumber>;
57
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
58
+ prefix: z.ZodDefault<z.ZodString>;
59
+ }, z.core.$strict>, z.ZodObject<{
60
+ provider: z.ZodLiteral<"vercel-blob">;
61
+ access: z.ZodLiteral<"public">;
62
+ tokenEnv: z.ZodString;
63
+ version: z.ZodLiteral<1>;
64
+ maxBytes: z.ZodDefault<z.ZodNumber>;
65
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
66
+ prefix: z.ZodDefault<z.ZodString>;
67
+ }, z.core.$strict>, z.ZodObject<{
68
+ provider: z.ZodLiteral<"directory">;
69
+ access: z.ZodLiteral<"public">;
70
+ directory: z.ZodString;
71
+ publicBaseUrl: z.ZodString;
72
+ version: z.ZodLiteral<1>;
73
+ maxBytes: z.ZodDefault<z.ZodNumber>;
74
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
75
+ prefix: z.ZodDefault<z.ZodString>;
76
+ }, z.core.$strict>], "provider">;
77
+ export type DeliveryConfig = z.infer<typeof configSchema>;
78
+ export declare function validateDeliveryConfig(value: unknown): DeliveryConfig;
79
+ export declare function secret(env: NodeJS.ProcessEnv, name: string): string;
80
+ export declare function publicUrl(base: string, key: string): string;
package/dist/config.js ADDED
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { isAbsolute } from 'node:path';
3
+ const url = z.string().url().refine(v => { const u = new URL(v); return u.protocol === 'https:' && !u.username && !u.password && !u.search && !u.hash; }, 'Use HTTPS without credentials, query or fragment');
4
+ const env = z.string().regex(/^[A-Z][A-Z0-9_]*$/);
5
+ const common = { version: z.literal(1), maxBytes: z.number().int().min(1).max(104857600).default(104857600), timeoutMs: z.number().int().min(1).max(120000).default(30000), prefix: z.string().regex(/^(?:[a-zA-Z0-9_-]+\/)*$/).default('deliveries/') };
6
+ const s3 = { ...common, endpoint: url.optional(), bucket: z.string().regex(/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/), region: z.string().min(1), forcePathStyle: z.boolean().default(false), accessKeyIdEnv: env, secretAccessKeyEnv: env, sessionTokenEnv: env.optional(), access: z.enum(['signed', 'public']).default('signed'), expiresIn: z.number().int().min(60).max(604800).default(86400), publicBaseUrl: url.optional() };
7
+ export const configSchema = z.discriminatedUnion('provider', [
8
+ z.object({ ...s3, provider: z.literal('r2'), region: z.literal('auto'), endpoint: url }).strict(),
9
+ z.object({ ...s3, provider: z.literal('s3') }).strict(),
10
+ z.object({ ...s3, provider: z.literal('s3-compatible'), endpoint: url }).strict(),
11
+ z.object({ ...common, provider: z.literal('vercel-blob'), access: z.literal('public'), tokenEnv: env }).strict(),
12
+ z.object({ ...common, provider: z.literal('directory'), access: z.literal('public'), directory: z.string().refine(isAbsolute, 'Use an absolute directory'), publicBaseUrl: url }).strict(),
13
+ ]).superRefine((c, ctx) => { if ('bucket' in c && c.access === 'public' && !c.publicBaseUrl)
14
+ ctx.addIssue({ code: 'custom', message: 'Public delivery requires publicBaseUrl' }); });
15
+ export function validateDeliveryConfig(value) { return configSchema.parse(value); }
16
+ export function secret(env, name) {
17
+ const value = env[name];
18
+ if (!value || /[\r\n\0]/.test(value))
19
+ throw new Error('delivery_credential_missing_or_invalid');
20
+ return value;
21
+ }
22
+ export function publicUrl(base, key) { return `${base.replace(/\/$/, '')}/${key.split('/').map(encodeURIComponent).join('/')}`; }
package/dist/eve.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { type DeliveryConfig, type DriverOptions } from './index.js';
2
+ /** Mount only on an authorized agent. Reads its own sandbox, never host file paths. */
3
+ export declare function deliverTool(options: {
4
+ config: DeliveryConfig | string;
5
+ driverOptions?: DriverOptions;
6
+ }): import("eve/tools").ToolDefinition<{
7
+ path: string;
8
+ filename?: string | undefined;
9
+ }, import("./index.js").DeliveryResult> & {
10
+ execute(input: {
11
+ path: string;
12
+ filename?: string | undefined;
13
+ }, ctx: import("eve/tools").ToolContext): Promise<import("./index.js").DeliveryResult>;
14
+ };
15
+ /** Setup-generated mount. Paths resolve on the trusted host, not in the sandbox. */
16
+ export declare function configuredDeliverTool(configPath?: string, credentialsPath?: string): import("eve/tools").ToolDefinition<{
17
+ path: string;
18
+ filename?: string | undefined;
19
+ }, import("./index.js").DeliveryResult | AsyncIterable<import("./index.js").DeliveryResult>> & {
20
+ execute(input: {
21
+ path: string;
22
+ filename?: string | undefined;
23
+ }, ctx: import("eve/tools").ToolContext): Promise<import("./index.js").DeliveryResult | AsyncIterable<import("./index.js").DeliveryResult>>;
24
+ };
package/dist/eve.js ADDED
@@ -0,0 +1,23 @@
1
+ import { defineTool } from 'eve/tools';
2
+ import { z } from 'zod';
3
+ import { boundedWait } from '@tealbrick/provider-transport';
4
+ import { createDeliver, loadDeliveryConfig } from './index.js';
5
+ /** Mount only on an authorized agent. Reads its own sandbox, never host file paths. */
6
+ export function deliverTool(options) {
7
+ return defineTool({ description: 'Deliver a file from this sandbox to the user through the configured storage provider. Returns a downloadable link and its access/expiry. Use for requested documents, reports and exports.', inputSchema: z.object({ path: z.string().startsWith('/').max(4096), filename: z.string().max(180).optional() }).strict(),
8
+ async execute({ path, filename }, context) {
9
+ const c = typeof options.config === 'string' ? await loadDeliveryConfig(options.config) : options.config;
10
+ const signal = AbortSignal.any([context.abortSignal, AbortSignal.timeout(c.timeoutMs)]);
11
+ const sandbox = await boundedWait(context.getSandbox(), signal);
12
+ const data = await boundedWait(Promise.resolve(sandbox.readBinaryFile({ path })), signal);
13
+ if (!data)
14
+ throw Error('delivery_file_not_found');
15
+ return createDeliver(c, options.driverOptions)({ data, filename: filename ?? path.split('/').pop() ?? 'file' }, signal);
16
+ } });
17
+ }
18
+ /** Setup-generated mount. Paths resolve on the trusted host, not in the sandbox. */
19
+ export function configuredDeliverTool(configPath = '.tealbrick/deliver.json', credentialsPath = '.tealbrick/deliver.credentials.json') {
20
+ // Lazy loading allows consumer discovery/build without production secrets.
21
+ return defineTool({ description: 'Deliver a requested sandbox file as a downloadable link using the configured Teal Brick provider. Report link expiry when present.', inputSchema: z.object({ path: z.string().startsWith('/').max(4096), filename: z.string().max(180).optional() }).strict(),
22
+ async execute(input, context) { const { loadCredentialFile } = await import('./setup.js'); const env = { ...process.env, ...await loadCredentialFile(credentialsPath) }; return deliverTool({ config: configPath, driverOptions: { env } }).execute(input, context); } });
23
+ }
@@ -0,0 +1,114 @@
1
+ import { S3Client } from '@aws-sdk/client-s3';
2
+ import { put, del } from '@vercel/blob';
3
+ import { type DeliveryConfig } from './config.js';
4
+ export { validateDeliveryConfig, type DeliveryConfig } from './config.js';
5
+ export interface DeliveryResult {
6
+ url: string;
7
+ objectKey: string;
8
+ filename: string;
9
+ contentType: string;
10
+ byteSize: number;
11
+ sha256: string;
12
+ access: 'public' | 'signed';
13
+ expiresAt?: string;
14
+ note: string;
15
+ }
16
+ export interface DeliveryDriver {
17
+ upload(key: string, data: Uint8Array, contentType: string, filename: string, signal: AbortSignal): Promise<{
18
+ url: string;
19
+ expiresAt?: string;
20
+ }>;
21
+ remove(key: string, signal: AbortSignal): Promise<void>;
22
+ }
23
+ export interface DriverOptions {
24
+ env?: NodeJS.ProcessEnv;
25
+ s3Client?: S3Client;
26
+ blob?: {
27
+ put: typeof put;
28
+ del: typeof del;
29
+ };
30
+ }
31
+ export declare function createDeliveryDriver(config: DeliveryConfig, options?: DriverOptions): DeliveryDriver;
32
+ export declare function deliveryFilename(value: string): string;
33
+ export declare function createDeliver(config: DeliveryConfig, options?: DriverOptions & {
34
+ driver?: DeliveryDriver;
35
+ }): (input: {
36
+ data: Uint8Array;
37
+ filename: string;
38
+ }, signal?: AbortSignal) => Promise<DeliveryResult>;
39
+ export declare function loadDeliveryConfig(path: string): Promise<{
40
+ provider: "r2";
41
+ region: "auto";
42
+ endpoint: string;
43
+ bucket: string;
44
+ forcePathStyle: boolean;
45
+ accessKeyIdEnv: string;
46
+ secretAccessKeyEnv: string;
47
+ access: "signed" | "public";
48
+ expiresIn: number;
49
+ version: 1;
50
+ maxBytes: number;
51
+ timeoutMs: number;
52
+ prefix: string;
53
+ sessionTokenEnv?: string | undefined;
54
+ publicBaseUrl?: string | undefined;
55
+ } | {
56
+ provider: "s3";
57
+ bucket: string;
58
+ region: string;
59
+ forcePathStyle: boolean;
60
+ accessKeyIdEnv: string;
61
+ secretAccessKeyEnv: string;
62
+ access: "signed" | "public";
63
+ expiresIn: number;
64
+ version: 1;
65
+ maxBytes: number;
66
+ timeoutMs: number;
67
+ prefix: string;
68
+ endpoint?: string | undefined;
69
+ sessionTokenEnv?: string | undefined;
70
+ publicBaseUrl?: string | undefined;
71
+ } | {
72
+ provider: "s3-compatible";
73
+ endpoint: string;
74
+ bucket: string;
75
+ region: string;
76
+ forcePathStyle: boolean;
77
+ accessKeyIdEnv: string;
78
+ secretAccessKeyEnv: string;
79
+ access: "signed" | "public";
80
+ expiresIn: number;
81
+ version: 1;
82
+ maxBytes: number;
83
+ timeoutMs: number;
84
+ prefix: string;
85
+ sessionTokenEnv?: string | undefined;
86
+ publicBaseUrl?: string | undefined;
87
+ } | {
88
+ provider: "vercel-blob";
89
+ access: "public";
90
+ tokenEnv: string;
91
+ version: 1;
92
+ maxBytes: number;
93
+ timeoutMs: number;
94
+ prefix: string;
95
+ } | {
96
+ provider: "directory";
97
+ access: "public";
98
+ directory: string;
99
+ publicBaseUrl: string;
100
+ version: 1;
101
+ maxBytes: number;
102
+ timeoutMs: number;
103
+ prefix: string;
104
+ }>;
105
+ /** Explicit setup check: uploads disposable bytes, verifies the recipient URL, deletes its own object. */
106
+ export declare function verifyDelivery(config: DeliveryConfig, options?: DriverOptions & {
107
+ driver?: DeliveryDriver;
108
+ fetch?: typeof fetch;
109
+ }): Promise<{
110
+ cleanupKey?: string | undefined;
111
+ ok: boolean;
112
+ downloadVerified: boolean;
113
+ cleanupVerified: boolean;
114
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,86 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, realpath, writeFile, unlink } from 'node:fs/promises';
3
+ import { dirname, resolve, sep } from 'node:path';
4
+ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
5
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
6
+ import { put, del } from '@vercel/blob';
7
+ import { boundedWait, bytes } from '@tealbrick/provider-transport';
8
+ import { validateDeliveryConfig, secret, publicUrl } from './config.js';
9
+ export { validateDeliveryConfig } from './config.js';
10
+ function checkedUrl(value) { const u = new URL(value); if (u.protocol !== 'https:' || u.username || u.password)
11
+ throw new Error('delivery_invalid_result_url'); return u.href; }
12
+ export function createDeliveryDriver(config, options = {}) {
13
+ const c = validateDeliveryConfig(config), env = options.env ?? process.env;
14
+ if (c.provider === 'directory') {
15
+ const directory = c.directory;
16
+ // Directory and its parents are operator-owned, never writable by sandbox users.
17
+ async function target(key) { const root = await realpath(directory); const path = resolve(root, key); if (!path.startsWith(root + sep))
18
+ throw Error('delivery_path_invalid'); await mkdir(dirname(path), { recursive: true, mode: 0o755 }); if (await realpath(dirname(path)) !== dirname(path))
19
+ throw Error('delivery_directory_symlink'); return path; }
20
+ return { async upload(key, data) { const path = await target(key); await writeFile(path, data, { flag: 'wx', mode: 0o644 }); return { url: publicUrl(c.publicBaseUrl, key) }; }, async remove(key) { await unlink(await target(key)); } };
21
+ }
22
+ if (c.provider === 'vercel-blob') {
23
+ const sdk = options.blob ?? { put, del }, token = secret(env, c.tokenEnv);
24
+ return { async upload(key, data, contentType, _filename, signal) { const result = await sdk.put(key, Buffer.from(data), { token, access: 'public', addRandomSuffix: false, contentType, abortSignal: signal }); return { url: checkedUrl(result.url) }; }, async remove(key) { await sdk.del(key, { token }); } };
25
+ }
26
+ const client = options.s3Client ?? new S3Client({ region: c.region, endpoint: c.endpoint, forcePathStyle: c.forcePathStyle, maxAttempts: 1, credentials: { accessKeyId: secret(env, c.accessKeyIdEnv), secretAccessKey: secret(env, c.secretAccessKeyEnv), ...(c.sessionTokenEnv ? { sessionToken: secret(env, c.sessionTokenEnv) } : {}) } });
27
+ return {
28
+ async upload(key, data, contentType, filename, signal) {
29
+ await client.send(new PutObjectCommand({ Bucket: c.bucket, Key: key, Body: data, ContentType: contentType, ContentDisposition: `attachment; filename="${filename}"` }), { abortSignal: signal });
30
+ if (c.access === 'public')
31
+ return { url: publicUrl(c.publicBaseUrl, key) };
32
+ const url = await getSignedUrl(client, new GetObjectCommand({ Bucket: c.bucket, Key: key }), { expiresIn: c.expiresIn });
33
+ return { url: checkedUrl(url), expiresAt: new Date(Date.now() + c.expiresIn * 1000).toISOString() };
34
+ },
35
+ async remove(key, signal) { await client.send(new DeleteObjectCommand({ Bucket: c.bucket, Key: key }), { abortSignal: signal }); },
36
+ };
37
+ }
38
+ const mime = { pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', zip: 'application/zip', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
39
+ export function deliveryFilename(value) { const name = value.replace(/[^a-zA-Z0-9._()+ -]/g, '_').replace(/^\.+/, '').slice(0, 180); return name || 'file'; }
40
+ export function createDeliver(config, options = {}) {
41
+ const c = validateDeliveryConfig(config);
42
+ return async (input, signal = new AbortController().signal) => {
43
+ if (!(input.data instanceof Uint8Array) || input.data.byteLength > c.maxBytes)
44
+ throw Error('delivery_file_too_large_or_invalid');
45
+ signal = AbortSignal.any([signal, AbortSignal.timeout(c.timeoutMs)]);
46
+ signal.throwIfAborted();
47
+ const filename = deliveryFilename(input.filename), ext = filename.split('.').pop().toLowerCase();
48
+ // Unknown/text/HTML/SVG formats are downloads, never executable web pages.
49
+ const contentType = mime[ext] ?? 'application/octet-stream';
50
+ const key = `${c.prefix}${randomUUID()}/${filename}`;
51
+ try {
52
+ const driver = options.driver ?? createDeliveryDriver(c, options);
53
+ const result = await boundedWait(driver.upload(key, input.data, contentType, filename, signal), signal);
54
+ return { ...result, objectKey: key, url: checkedUrl(result.url), filename, contentType, byteSize: input.data.byteLength, sha256: createHash('sha256').update(input.data).digest('hex'), access: c.access, note: c.access === 'public' ? 'Anyone with this link can download the file.' : 'This download link expires; use expiresAt. Anyone with the link can download until then.' };
55
+ }
56
+ catch {
57
+ const code = signal.aborted ? 'delivery_cancelled_or_timed_out_upload_may_exist' : 'delivery_failed_upload_may_exist';
58
+ throw Object.assign(new Error(`${code}; reconcile object ${key} before retrying`), { code, objectKey: key });
59
+ }
60
+ };
61
+ }
62
+ export async function loadDeliveryConfig(path) { return validateDeliveryConfig(JSON.parse(await readFile(path, 'utf8'))); }
63
+ /** Explicit setup check: uploads disposable bytes, verifies the recipient URL, deletes its own object. */
64
+ export async function verifyDelivery(config, options = {}) {
65
+ const c = validateDeliveryConfig(config), driver = options.driver ?? createDeliveryDriver(c, options), key = `${c.prefix}setup-check-${randomUUID()}.txt`, data = Buffer.from(`Teal Brick delivery check ${randomUUID()}`);
66
+ const signal = AbortSignal.timeout(c.timeoutMs);
67
+ let verified = false, cleaned = false;
68
+ try {
69
+ const result = await boundedWait(driver.upload(key, data, 'text/plain', 'setup-check.txt', signal), signal);
70
+ const response = await boundedWait((options.fetch ?? fetch)(checkedUrl(result.url), { redirect: 'error', signal }), signal);
71
+ if (!response.ok)
72
+ throw Error('delivery_check_download_failed');
73
+ const received = await bytes(response.body, 4096, signal);
74
+ verified = Buffer.from(received).equals(data);
75
+ }
76
+ catch { /* Return redacted result, never a URL or SDK error with credentials. */ }
77
+ finally {
78
+ try {
79
+ const cleanupSignal = AbortSignal.timeout(c.timeoutMs);
80
+ await boundedWait(driver.remove(key, cleanupSignal), cleanupSignal);
81
+ cleaned = true;
82
+ }
83
+ catch { }
84
+ }
85
+ return { ok: verified && cleaned, downloadVerified: verified, cleanupVerified: cleaned, ...(!cleaned ? { cleanupKey: key } : {}) };
86
+ }
@@ -0,0 +1,83 @@
1
+ import { type DeliveryConfig } from './index.js';
2
+ export interface SetupPrompt {
3
+ select(message: string, choices: string[]): Promise<string>;
4
+ input(message: string, defaultValue?: string): Promise<string>;
5
+ password(message: string): Promise<string>;
6
+ confirm(message: string): Promise<boolean>;
7
+ }
8
+ /** Provider is chosen before any provider-specific questions. Secrets are separate. */
9
+ export declare function collectSetup(prompt: SetupPrompt): Promise<{
10
+ config: {
11
+ provider: "r2";
12
+ region: "auto";
13
+ endpoint: string;
14
+ bucket: string;
15
+ forcePathStyle: boolean;
16
+ accessKeyIdEnv: string;
17
+ secretAccessKeyEnv: string;
18
+ access: "signed" | "public";
19
+ expiresIn: number;
20
+ version: 1;
21
+ maxBytes: number;
22
+ timeoutMs: number;
23
+ prefix: string;
24
+ sessionTokenEnv?: string | undefined;
25
+ publicBaseUrl?: string | undefined;
26
+ } | {
27
+ provider: "s3";
28
+ bucket: string;
29
+ region: string;
30
+ forcePathStyle: boolean;
31
+ accessKeyIdEnv: string;
32
+ secretAccessKeyEnv: string;
33
+ access: "signed" | "public";
34
+ expiresIn: number;
35
+ version: 1;
36
+ maxBytes: number;
37
+ timeoutMs: number;
38
+ prefix: string;
39
+ endpoint?: string | undefined;
40
+ sessionTokenEnv?: string | undefined;
41
+ publicBaseUrl?: string | undefined;
42
+ } | {
43
+ provider: "s3-compatible";
44
+ endpoint: string;
45
+ bucket: string;
46
+ region: string;
47
+ forcePathStyle: boolean;
48
+ accessKeyIdEnv: string;
49
+ secretAccessKeyEnv: string;
50
+ access: "signed" | "public";
51
+ expiresIn: number;
52
+ version: 1;
53
+ maxBytes: number;
54
+ timeoutMs: number;
55
+ prefix: string;
56
+ sessionTokenEnv?: string | undefined;
57
+ publicBaseUrl?: string | undefined;
58
+ } | {
59
+ provider: "vercel-blob";
60
+ access: "public";
61
+ tokenEnv: string;
62
+ version: 1;
63
+ maxBytes: number;
64
+ timeoutMs: number;
65
+ prefix: string;
66
+ } | {
67
+ provider: "directory";
68
+ access: "public";
69
+ directory: string;
70
+ publicBaseUrl: string;
71
+ version: 1;
72
+ maxBytes: number;
73
+ timeoutMs: number;
74
+ prefix: string;
75
+ };
76
+ credentials: Record<string, string>;
77
+ }>;
78
+ export declare function loadCredentialFile(path: string): Promise<NodeJS.ProcessEnv>;
79
+ export declare function saveSetup(root: string, config: DeliveryConfig, credentials: Record<string, string>): Promise<{
80
+ configPath: string;
81
+ credentialsPath: string;
82
+ mountPath: string;
83
+ }>;
package/dist/setup.js ADDED
@@ -0,0 +1,96 @@
1
+ import { mkdir, writeFile, readFile, lstat, access } from 'node:fs/promises';
2
+ import { resolve, dirname, join } from 'node:path';
3
+ import { validateDeliveryConfig } from './index.js';
4
+ /** Provider is chosen before any provider-specific questions. Secrets are separate. */
5
+ export async function collectSetup(prompt) {
6
+ const provider = await prompt.select('File delivery provider', ['r2', 's3', 's3-compatible', 'vercel-blob', 'directory']);
7
+ const c = { version: 1, provider };
8
+ const credentials = {};
9
+ if (provider === 'directory') {
10
+ c.directory = await prompt.input('Absolute directory served by your web server');
11
+ c.publicBaseUrl = await prompt.input('HTTPS URL serving that directory');
12
+ c.access = 'public';
13
+ }
14
+ else if (provider === 'vercel-blob') {
15
+ c.tokenEnv = 'TEALBRICK_DELIVER_BLOB_TOKEN';
16
+ credentials[c.tokenEnv] = await prompt.password('Vercel Blob token (public store)');
17
+ c.access = 'public';
18
+ }
19
+ else {
20
+ if (provider !== 's3')
21
+ c.endpoint = await prompt.input('S3 HTTPS endpoint', provider === 'r2' ? 'https://<account-id>.r2.cloudflarestorage.com' : undefined);
22
+ c.bucket = await prompt.input('Bucket name');
23
+ c.region = provider === 'r2' ? 'auto' : await prompt.input('Region', 'us-east-1');
24
+ c.forcePathStyle = provider === 's3-compatible' ? await prompt.confirm('Use path-style addressing?') : provider === 'r2';
25
+ c.accessKeyIdEnv = 'TEALBRICK_DELIVER_ACCESS_KEY_ID';
26
+ c.secretAccessKeyEnv = 'TEALBRICK_DELIVER_SECRET_ACCESS_KEY';
27
+ credentials[c.accessKeyIdEnv] = await prompt.password('Access key ID');
28
+ credentials[c.secretAccessKeyEnv] = await prompt.password('Secret access key');
29
+ c.access = await prompt.select('Download access', ['signed', 'public']);
30
+ if (c.access === 'signed')
31
+ c.expiresIn = Number(await prompt.input('Link lifetime in seconds (60–604800)', '86400'));
32
+ else
33
+ c.publicBaseUrl = await prompt.input('Public HTTPS base URL for this bucket');
34
+ }
35
+ if (c.access === 'public' && !await prompt.confirm('Anyone with a delivery link can download it. Use public delivery?'))
36
+ throw Error('setup_cancelled');
37
+ c.prefix = await prompt.input('Object prefix (end with /)', 'deliveries/');
38
+ for (const value of Object.values(credentials))
39
+ if (!value || /[\r\n\0]/.test(value))
40
+ throw Error('invalid_credentials');
41
+ return { config: validateDeliveryConfig(c), credentials };
42
+ }
43
+ export async function loadCredentialFile(path) {
44
+ try {
45
+ const info = await lstat(path);
46
+ if (!info.isFile() || info.isSymbolicLink() || (process.platform !== 'win32' && (info.mode & 0o077) !== 0))
47
+ throw Error('delivery_credentials_permissions');
48
+ const data = JSON.parse(await readFile(path, 'utf8'));
49
+ if (!data || Array.isArray(data) || typeof data !== 'object' || Object.entries(data).some(([k, v]) => !/^TEALBRICK_DELIVER_[A-Z_]+$/.test(k) || typeof v !== 'string' || /[\r\n\0]/.test(v)))
50
+ throw Error('delivery_credentials_invalid');
51
+ return data;
52
+ }
53
+ catch (error) {
54
+ if (error.code === 'ENOENT')
55
+ return {};
56
+ throw error;
57
+ }
58
+ }
59
+ export async function saveSetup(root, config, credentials) {
60
+ config = validateDeliveryConfig(config);
61
+ if (Object.entries(credentials).some(([k, v]) => !/^TEALBRICK_DELIVER_[A-Z_]+$/.test(k) || typeof v !== 'string' || !v || /[\r\n\0]/.test(v)))
62
+ throw Error('delivery_credentials_invalid');
63
+ const dir = resolve(root, '.tealbrick');
64
+ await mkdir(dir, { recursive: true, mode: 0o700 });
65
+ if ((await lstat(dir)).isSymbolicLink())
66
+ throw Error('delivery_settings_symlink');
67
+ // Fail before touching existing setup; never replace an existing delivery tool.
68
+ const files = [join(dir, 'deliver.json'), join(dir, 'deliver.credentials.json'), resolve(root, 'agent/tools/deliver.ts')];
69
+ for (const file of files) {
70
+ try {
71
+ await access(file);
72
+ throw Error('delivery_setup_already_exists');
73
+ }
74
+ catch (e) {
75
+ if (e.code !== 'ENOENT')
76
+ throw e;
77
+ }
78
+ }
79
+ const ignore = resolve(root, '.gitignore');
80
+ let old = '';
81
+ try {
82
+ old = await readFile(ignore, 'utf8');
83
+ }
84
+ catch (e) {
85
+ if (e.code !== 'ENOENT')
86
+ throw e;
87
+ }
88
+ // Protect local settings and secrets before creating them.
89
+ if (!old.split(/\r?\n/).includes('/.tealbrick/'))
90
+ await writeFile(ignore, `${old}${old.endsWith('\n') || !old ? '' : '\n'}/.tealbrick/\n`);
91
+ await writeFile(files[1], JSON.stringify(credentials, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
92
+ await writeFile(files[0], JSON.stringify(config, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
93
+ await mkdir(dirname(files[2]), { recursive: true });
94
+ await writeFile(files[2], `import {configuredDeliverTool} from '@tealbrick/deliver/eve';\nexport default configuredDeliverTool();\n`, { flag: 'wx' });
95
+ return { configPath: files[0], credentialsPath: files[1], mountPath: files[2] };
96
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@tealbrick/deliver",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "Provider-first file delivery for Eve and other Teal Brick hosts",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE",
14
+ "NOTICE"
15
+ ],
16
+ "bin": {
17
+ "tealbrick-deliver": "dist/cli.js"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./eve": {
25
+ "types": "./dist/eve.d.ts",
26
+ "import": "./dist/eve.js"
27
+ },
28
+ "./setup": {
29
+ "types": "./dist/setup.d.ts",
30
+ "import": "./dist/setup.js"
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "npm run build --workspace @tealbrick/provider-transport && tsc -p tsconfig.json",
35
+ "test": "node --test test/*.test.mjs"
36
+ },
37
+ "dependencies": {
38
+ "@aws-sdk/client-s3": "3.1132.0",
39
+ "@aws-sdk/s3-request-presigner": "3.1132.0",
40
+ "@vercel/blob": "2.8.0",
41
+ "@inquirer/prompts": "8.7.2",
42
+ "@tealbrick/provider-transport": "0.1.0",
43
+ "zod": "^4.0.0"
44
+ },
45
+ "peerDependencies": {
46
+ "eve": ">=0.55.0 <0.56.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "eve": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "typescript": "^5.9.3",
55
+ "@types/node": "^24.0.0"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "registry": "https://registry.npmjs.org"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/Doppelabs/tealbrick-packages.git",
64
+ "directory": "packages/deliver"
65
+ }
66
+ }