sliccy 2.27.2 → 2.28.1
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/README.md +2 -0
- package/dist/node-server/fetch-proxy-headers.d.ts +14 -0
- package/dist/node-server/fetch-proxy-headers.js +24 -0
- package/dist/node-server/index.js +69 -13
- package/dist/node-server/secrets/sign-and-forward.d.ts +63 -0
- package/dist/node-server/secrets/sign-and-forward.js +294 -0
- package/dist/node-server/secrets/signing-s3.d.ts +34 -0
- package/dist/node-server/secrets/signing-s3.js +141 -0
- package/dist/ui/assets/{anthropic-CRK4ZXzu.js → anthropic-BWzWdO8z.js} +2 -2
- package/dist/ui/assets/{azure-openai-responses-_ds8HlW8.js → azure-openai-responses-C_Kus9Nd.js} +1 -1
- package/dist/ui/assets/{dist-BVxswsnA.js → dist-B7jV583z.js} +2 -2
- package/dist/ui/assets/{dist-C_pwrhfY.js → dist-BDtNNOSH.js} +1 -1
- package/dist/ui/assets/{dist-DB19jrs9.js → dist-CGaze1Ny.js} +1 -1
- package/dist/ui/assets/{dist-BETSJsV5.js → dist-DNaqKK1b.js} +1 -1
- package/dist/ui/assets/{dist-Ck3Vla0r.js → dist-v3lhJeTU.js} +1 -1
- package/dist/ui/assets/{es-BrKITROV.js → es-BOB4iOb0.js} +1 -1
- package/dist/ui/assets/fs-e1alZjK0.js +4 -0
- package/dist/ui/assets/{google-CxztYoW5.js → google-BRKZ8On1.js} +1 -1
- package/dist/ui/assets/{google-shared-tCK79d6s.js → google-shared-C81jZNkD.js} +1 -1
- package/dist/ui/assets/{google-vertex-DVupsUV0.js → google-vertex-raK4Y0YQ.js} +1 -1
- package/dist/ui/assets/{index-C0TjzW-F.js → index-0BbIGOXn.js} +1095 -929
- package/dist/ui/assets/{magick-wasm-DpuDwl7G.js → magick-wasm-BzLDiChi.js} +1 -1
- package/dist/ui/assets/mime-types-D4mOIoVQ.js +1 -0
- package/dist/ui/assets/{mistral-C_y1UGQh.js → mistral-rZDpSiTo.js} +1 -1
- package/dist/ui/assets/mount-BQ4kuQ0Y.js +1 -0
- package/dist/ui/assets/mount-id-aPpP4D52.js +1 -0
- package/dist/ui/assets/mount-table-store-CpeaTcOk.js +1 -0
- package/dist/ui/assets/{onboarding-orchestrator-DeO0Zc8w.js → onboarding-orchestrator-DwdqRmPh.js} +1 -1
- package/dist/ui/assets/{openai-codex-responses-CdCelSnj.js → openai-codex-responses-C6qbMWcB.js} +2 -2
- package/dist/ui/assets/{openai-completions-tCMB1RoI.js → openai-completions-ByjULPID.js} +1 -1
- package/dist/ui/assets/{openai-responses-CkCoDrxr.js → openai-responses-WkNYb9vR.js} +1 -1
- package/dist/ui/assets/{pdfjs-C0bwNEC1.js → pdfjs-BoIgQO-R.js} +1 -1
- package/dist/ui/assets/provider-settings-D4bk6FeT.js +95 -0
- package/dist/ui/assets/{pyodide-DCMrvZhT.js → pyodide-O3MDgCSV.js} +3 -3
- package/dist/ui/assets/remote-cache-BMnV55WX.js +1 -0
- package/dist/ui/assets/{skills-DclcqNBg.js → skills-Bu1MXwQO.js} +1 -1
- package/dist/ui/assets/{slicc-editor-C9a_yd9R.js → slicc-editor-BoYj4oN5.js} +2 -2
- package/dist/ui/assets/{sql-wasm-GAMLdFdJ.js → sql-wasm-ClMy9maw.js} +1 -1
- package/dist/ui/index.html +23 -18
- package/dist/ui/packages/webapp/index.html +23 -18
- package/package.json +1 -1
- package/dist/ui/assets/fs-t3D13Hd5.js +0 -3
- package/dist/ui/assets/provider-settings-CmNVAyBP.js +0 -95
- /package/dist/ui/assets/{__vite-browser-external-BD_HvDfc.js → __vite-browser-external-B-U7N2L4.js} +0 -0
- /package/dist/ui/assets/{addon-fit-CniRnSyE.js → addon-fit-CnTv21Qe.js} +0 -0
- /package/dist/ui/assets/{browser-CFDl2kC3.js → browser-BdRkQqGs.js} +0 -0
- /package/dist/ui/assets/{bsh-watchdog-DWN1spIr.js → bsh-watchdog-D2IOkdFT.js} +0 -0
- /package/dist/ui/assets/{chat-fixture-BGLmPevX.js → chat-fixture-BUlIqjmp.js} +0 -0
- /package/dist/ui/assets/{db-CmHnV4DS.js → db-CogIG09c.js} +0 -0
- /package/dist/ui/assets/{dist-CkF25Z3T.js → dist-D3b8uvJq.js} +0 -0
- /package/dist/ui/assets/{dist-uJp2dR6X.js → dist-DDsR80Yu.js} +0 -0
- /package/dist/ui/assets/{fetch-body-DPMYsjzA.js → fetch-body-DlhSI6C_.js} +0 -0
- /package/dist/ui/assets/{github-copilot-headers-DGaOrqaW.js → github-copilot-headers-CILwC7Cq.js} +0 -0
- /package/dist/ui/assets/{global-db-Cui6Md0w.js → global-db-Ch1iyQkU.js} +0 -0
- /package/dist/ui/assets/{hash-BN8UQrrC.js → hash-Bt1aVMQ3.js} +0 -0
- /package/dist/ui/assets/{headers-qDMOQQGF.js → headers-C5V-E251.js} +0 -0
- /package/dist/ui/assets/{lick-manager-proxy-Dw3D3AzG.js → lick-manager-proxy-CogaR9kt.js} +0 -0
- /package/dist/ui/assets/{oauth-service-DUXASG7L.js → oauth-service-CfTUWB9P.js} +0 -0
- /package/dist/ui/assets/{offscreen-client-DCrdvikK.js → offscreen-client-CfxxcGbf.js} +0 -0
- /package/dist/ui/assets/{openai-D4NSaQIs.js → openai-BR_xAflc.js} +0 -0
- /package/dist/ui/assets/{path-utils-Dzkyzd3x.js → path-utils-d3qOVZOu.js} +0 -0
- /package/dist/ui/assets/{preload-helper-ca-nBW7U.js → preload-helper-DzyYoeor.js} +0 -0
- /package/dist/ui/assets/{rum-sVedwbc1.js → rum-BEM52ixi.js} +0 -0
- /package/dist/ui/assets/{secret-masking-ZNVanUmG.js → secret-masking-Bg1u5YcF.js} +0 -0
- /package/dist/ui/assets/{simple-options-CDIijiMb.js → simple-options-xknIywaW.js} +0 -0
- /package/dist/ui/assets/{src-DSYf97Eh.js → src-CgSXdUB7.js} +0 -0
- /package/dist/ui/assets/{xterm-BaLOnk9X.js → xterm-CAZt1iF4.js} +0 -0
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ SLICC is for you if:
|
|
|
58
58
|
- **Delegate parallel work to scoops.** Split tasks into isolated sub-agents with their own sandboxes and context, then let the main agent coordinate the results.
|
|
59
59
|
- **Turn one-off wins into reusable workflows.** Package behavior as skills, build interactive sprinkles, and react to external events with webhooks and cron-driven licks.
|
|
60
60
|
- **Mount your local file system.** By default, SLICC is confined to your browser. But you can ask it to mount folders from your local file system, so it can read and write from there. Mount into an empty path such as `/mnt/myproject` so you do not hide existing skills or scripts.
|
|
61
|
+
- **Mount remote storage as if it were local.** Beyond local folders, `mount --source` bridges S3 buckets, S3-compatible services like Cloudflare R2 and MinIO, and Adobe da.live repositories into the same VFS surface. Reads use TTL+ETag caching with conditional revalidation; writes use ETag-conditional PUTs that surface concurrent-edit conflicts as `EBUSY`. Credentials live server-side (`~/.slicc/secrets.env` in CLI, `chrome.storage.local` in the extension via the **Extension options** page) and never reach the agent. After setup: `mount --source s3://my-bucket --profile r2 /mnt/r2` or `mount --source da://my-org/my-repo /mnt/da`. See [docs/mounts.md](docs/mounts.md) for the full guide.
|
|
61
62
|
|
|
62
63
|
## Getting started
|
|
63
64
|
|
|
@@ -184,5 +185,6 @@ If you want to go deeper, the detailed docs live here:
|
|
|
184
185
|
- [Testing](docs/testing.md)
|
|
185
186
|
- [Shell reference](docs/shell-reference.md)
|
|
186
187
|
- [Secrets](docs/secrets.md)
|
|
188
|
+
- [Mounts (local + S3 / R2 / DA)](docs/mounts.md)
|
|
187
189
|
- [Adding features](docs/adding-features.md)
|
|
188
190
|
- [Electron notes](docs/electron.md)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headers the `/api/fetch-proxy` route does NOT forward upstream.
|
|
3
|
+
*
|
|
4
|
+
* Includes hop-by-hop headers (`host`, `connection`, `transfer-encoding`,
|
|
5
|
+
* `content-length`), proxy-internal markers (`x-target-url`,
|
|
6
|
+
* `x-slicc-raw-body`), and forbidden-header transports the client uses
|
|
7
|
+
* to smuggle reserved names through `fetch()` (`x-proxy-cookie`,
|
|
8
|
+
* `x-proxy-origin`, `x-proxy-referer`).
|
|
9
|
+
*
|
|
10
|
+
* Lives in its own module (rather than `index.ts`) so tests can import
|
|
11
|
+
* it without triggering the server bootstrap that runs at `index.ts`
|
|
12
|
+
* module load.
|
|
13
|
+
*/
|
|
14
|
+
export declare const FETCH_PROXY_SKIP_HEADERS: ReadonlySet<string>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headers the `/api/fetch-proxy` route does NOT forward upstream.
|
|
3
|
+
*
|
|
4
|
+
* Includes hop-by-hop headers (`host`, `connection`, `transfer-encoding`,
|
|
5
|
+
* `content-length`), proxy-internal markers (`x-target-url`,
|
|
6
|
+
* `x-slicc-raw-body`), and forbidden-header transports the client uses
|
|
7
|
+
* to smuggle reserved names through `fetch()` (`x-proxy-cookie`,
|
|
8
|
+
* `x-proxy-origin`, `x-proxy-referer`).
|
|
9
|
+
*
|
|
10
|
+
* Lives in its own module (rather than `index.ts`) so tests can import
|
|
11
|
+
* it without triggering the server bootstrap that runs at `index.ts`
|
|
12
|
+
* module load.
|
|
13
|
+
*/
|
|
14
|
+
export const FETCH_PROXY_SKIP_HEADERS = new Set([
|
|
15
|
+
'host',
|
|
16
|
+
'connection',
|
|
17
|
+
'x-target-url',
|
|
18
|
+
'x-slicc-raw-body',
|
|
19
|
+
'content-length',
|
|
20
|
+
'transfer-encoding',
|
|
21
|
+
'x-proxy-cookie',
|
|
22
|
+
'x-proxy-origin',
|
|
23
|
+
'x-proxy-referer',
|
|
24
|
+
]);
|
|
@@ -18,6 +18,8 @@ import { FileLogger } from './file-logger.js';
|
|
|
18
18
|
import { CliLogDedup } from './cli-log-dedup.js';
|
|
19
19
|
import { EnvSecretStore } from './secrets/env-secret-store.js';
|
|
20
20
|
import { SecretProxyManager } from './secrets/proxy-manager.js';
|
|
21
|
+
import { handleDaSignAndForward, handleS3SignAndForward } from './secrets/sign-and-forward.js';
|
|
22
|
+
import { FETCH_PROXY_SKIP_HEADERS } from './fetch-proxy-headers.js';
|
|
21
23
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
22
24
|
const PROJECT_ROOT = resolve(__dirname, '..', '..');
|
|
23
25
|
const RUNTIME_FLAGS = parseCliRuntimeFlags(process.argv.slice(2));
|
|
@@ -630,7 +632,15 @@ async function main() {
|
|
|
630
632
|
res.status(204).end();
|
|
631
633
|
}
|
|
632
634
|
});
|
|
633
|
-
|
|
635
|
+
// Global JSON body parser. Skipped when the request carries
|
|
636
|
+
// `X-Slicc-Raw-Body: 1`, so SigV4-signed bodies survive into the
|
|
637
|
+
// /api/fetch-proxy handler byte-for-byte (the parser would otherwise
|
|
638
|
+
// re-serialize them via JSON.stringify, breaking the signature).
|
|
639
|
+
app.use(express.json({
|
|
640
|
+
limit: '50mb',
|
|
641
|
+
type: (req) => req.headers['x-slicc-raw-body'] !== '1' &&
|
|
642
|
+
(req.headers['content-type'] ?? '').includes('application/json'),
|
|
643
|
+
}));
|
|
634
644
|
app.get('/api/runtime-config', (_req, res) => {
|
|
635
645
|
res.json({
|
|
636
646
|
trayWorkerBaseUrl: RUNTIME_FLAGS.leadWorkerBaseUrl ??
|
|
@@ -795,6 +805,60 @@ async function main() {
|
|
|
795
805
|
.json({ error: err instanceof Error ? err.message : 'Failed to list secrets' });
|
|
796
806
|
}
|
|
797
807
|
});
|
|
808
|
+
// S3 sign-and-forward — browser-side mount backend posts envelopes here;
|
|
809
|
+
// server resolves the s3.<profile>.* secrets, signs SigV4 v4, forwards to
|
|
810
|
+
// the upstream, returns the response as a JSON envelope. The browser
|
|
811
|
+
// never sees access_key_id / secret_access_key. See sign-and-forward.ts
|
|
812
|
+
// for the envelope contract.
|
|
813
|
+
app.post('/api/s3-sign-and-forward', async (req, res) => {
|
|
814
|
+
try {
|
|
815
|
+
await handleS3SignAndForward(req, res, secretStore);
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
// Generic log line + trace id only. Avoid logging the err.message
|
|
819
|
+
// because TypeError stack frames or signing errors can include
|
|
820
|
+
// profile names, bucket names, or partial URLs — operational secrets
|
|
821
|
+
// we don't want in shared log aggregators (Sentry, Datadog, etc.).
|
|
822
|
+
// The trace id lets users correlate a server-side log with the
|
|
823
|
+
// 500 the client got; the detailed message goes only to the local
|
|
824
|
+
// file logger above DEBUG, where it's bounded to the operator.
|
|
825
|
+
const traceId = (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10)).slice(0, 8);
|
|
826
|
+
console.error(`S3 sign-and-forward error [trace=${traceId}]`);
|
|
827
|
+
if (DEV_MODE) {
|
|
828
|
+
console.error(err);
|
|
829
|
+
}
|
|
830
|
+
if (!res.headersSent) {
|
|
831
|
+
res.status(500).json({
|
|
832
|
+
ok: false,
|
|
833
|
+
error: `internal sign-and-forward error [trace=${traceId}]`,
|
|
834
|
+
errorCode: 'internal',
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
// DA sign-and-forward — same pattern as S3, but for Adobe da.live. The
|
|
840
|
+
// IMS bearer token is passed transiently in the envelope (browser holds
|
|
841
|
+
// it via the existing Adobe LLM provider). v2 will move OAuth server-side
|
|
842
|
+
// to remove the browser exposure entirely.
|
|
843
|
+
app.post('/api/da-sign-and-forward', async (req, res) => {
|
|
844
|
+
try {
|
|
845
|
+
await handleDaSignAndForward(req, res);
|
|
846
|
+
}
|
|
847
|
+
catch (err) {
|
|
848
|
+
const traceId = (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 10)).slice(0, 8);
|
|
849
|
+
console.error(`DA sign-and-forward error [trace=${traceId}]`);
|
|
850
|
+
if (DEV_MODE) {
|
|
851
|
+
console.error(err);
|
|
852
|
+
}
|
|
853
|
+
if (!res.headersSent) {
|
|
854
|
+
res.status(500).json({
|
|
855
|
+
ok: false,
|
|
856
|
+
error: `internal sign-and-forward error [trace=${traceId}]`,
|
|
857
|
+
errorCode: 'internal',
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
});
|
|
798
862
|
// Masked secrets endpoint — returns name + maskedValue pairs for shell env population.
|
|
799
863
|
// The browser fetches this at shell init to populate env vars with masked values.
|
|
800
864
|
// Real values are never exposed; only deterministic session-scoped masks.
|
|
@@ -842,20 +906,12 @@ async function main() {
|
|
|
842
906
|
method: req.method,
|
|
843
907
|
redirect: 'follow', // Follow redirects for git protocol compatibility
|
|
844
908
|
};
|
|
845
|
-
// Forward relevant headers (excluding hop-by-hop and proxy headers)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
'connection',
|
|
849
|
-
'x-target-url',
|
|
850
|
-
'content-length',
|
|
851
|
-
'transfer-encoding',
|
|
852
|
-
'x-proxy-cookie',
|
|
853
|
-
'x-proxy-origin',
|
|
854
|
-
'x-proxy-referer',
|
|
855
|
-
]);
|
|
909
|
+
// Forward relevant headers (excluding hop-by-hop and proxy headers).
|
|
910
|
+
// Set lives at module scope as FETCH_PROXY_SKIP_HEADERS so tests can
|
|
911
|
+
// verify the contract without copying it.
|
|
856
912
|
const headers = {};
|
|
857
913
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
858
|
-
if (!
|
|
914
|
+
if (!FETCH_PROXY_SKIP_HEADERS.has(key) && typeof value === 'string') {
|
|
859
915
|
headers[key] = value;
|
|
860
916
|
}
|
|
861
917
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side request signing for S3 and Adobe da.live mounts.
|
|
3
|
+
*
|
|
4
|
+
* The browser-side mount backends never see real S3 credentials or the IMS
|
|
5
|
+
* bearer token. They post envelopes to `/api/s3-sign-and-forward` and
|
|
6
|
+
* `/api/da-sign-and-forward`, which:
|
|
7
|
+
* 1. Validate the envelope.
|
|
8
|
+
* 2. Resolve credentials server-side (S3) or accept a transient bearer (DA).
|
|
9
|
+
* 3. Reconstruct the upstream URL from profile config (S3) or the path
|
|
10
|
+
* prefix (DA) — so the browser cannot SSRF arbitrary hosts.
|
|
11
|
+
* 4. Sign with SigV4 v4 (S3) or attach `Authorization: Bearer` (DA).
|
|
12
|
+
* 5. Forward to the upstream and return the response as a JSON envelope.
|
|
13
|
+
*
|
|
14
|
+
* Logging contract: never log envelope contents — request bodies or the
|
|
15
|
+
* `imsToken` may contain credential material.
|
|
16
|
+
*/
|
|
17
|
+
import type { Request, Response } from 'express';
|
|
18
|
+
import type { SecretStore } from './types.js';
|
|
19
|
+
/** Methods we permit through the signed proxies. */
|
|
20
|
+
declare const ALLOWED_METHODS: readonly ["GET", "PUT", "POST", "DELETE", "HEAD"];
|
|
21
|
+
type SignedMethod = (typeof ALLOWED_METHODS)[number];
|
|
22
|
+
export interface S3SignAndForwardEnvelope {
|
|
23
|
+
profile: string;
|
|
24
|
+
method: SignedMethod;
|
|
25
|
+
bucket: string;
|
|
26
|
+
/** S3 key (the prefix is already baked in by the backend). */
|
|
27
|
+
key: string;
|
|
28
|
+
query?: Record<string, string>;
|
|
29
|
+
/** Extra headers from the backend (If-Match, Content-Type, ...). */
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
/** Request body, base64-encoded. Null/absent for GET/HEAD/DELETE/listing. */
|
|
32
|
+
bodyBase64?: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface DaSignAndForwardEnvelope {
|
|
35
|
+
/** IMS bearer token, passed transiently. Never persisted server-side. */
|
|
36
|
+
imsToken: string;
|
|
37
|
+
method: SignedMethod;
|
|
38
|
+
/** Path including leading slash, e.g. `/source/<org>/<repo>/<key>`. */
|
|
39
|
+
path: string;
|
|
40
|
+
query?: Record<string, string>;
|
|
41
|
+
headers?: Record<string, string>;
|
|
42
|
+
bodyBase64?: string | null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Handle a `POST /api/s3-sign-and-forward` request. Validates the envelope,
|
|
46
|
+
* resolves credentials, signs, forwards, returns a JSON envelope.
|
|
47
|
+
*
|
|
48
|
+
* Errors in setup return 400 with a structured `{ ok: false, error, errorCode }`.
|
|
49
|
+
* Network errors against the upstream return 502.
|
|
50
|
+
*/
|
|
51
|
+
export declare function handleS3SignAndForward(req: Request, res: Response, secretStore: SecretStore): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Handle a `POST /api/da-sign-and-forward` request. Attaches the IMS bearer
|
|
54
|
+
* token (passed transiently in the envelope), forwards to da.live, returns
|
|
55
|
+
* a JSON envelope.
|
|
56
|
+
*
|
|
57
|
+
* v1: the IMS token comes from the browser at request time. The browser
|
|
58
|
+
* already holds the token via the existing Adobe LLM provider OAuth flow;
|
|
59
|
+
* routing through the server gives architectural symmetry with S3 and a
|
|
60
|
+
* place to tighten the threat model in v2 (server-side OAuth).
|
|
61
|
+
*/
|
|
62
|
+
export declare function handleDaSignAndForward(req: Request, res: Response): Promise<void>;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side request signing for S3 and Adobe da.live mounts.
|
|
3
|
+
*
|
|
4
|
+
* The browser-side mount backends never see real S3 credentials or the IMS
|
|
5
|
+
* bearer token. They post envelopes to `/api/s3-sign-and-forward` and
|
|
6
|
+
* `/api/da-sign-and-forward`, which:
|
|
7
|
+
* 1. Validate the envelope.
|
|
8
|
+
* 2. Resolve credentials server-side (S3) or accept a transient bearer (DA).
|
|
9
|
+
* 3. Reconstruct the upstream URL from profile config (S3) or the path
|
|
10
|
+
* prefix (DA) — so the browser cannot SSRF arbitrary hosts.
|
|
11
|
+
* 4. Sign with SigV4 v4 (S3) or attach `Authorization: Bearer` (DA).
|
|
12
|
+
* 5. Forward to the upstream and return the response as a JSON envelope.
|
|
13
|
+
*
|
|
14
|
+
* Logging contract: never log envelope contents — request bodies or the
|
|
15
|
+
* `imsToken` may contain credential material.
|
|
16
|
+
*/
|
|
17
|
+
import { signSigV4 } from './signing-s3.js';
|
|
18
|
+
/** Allowed characters in profile names — restricts secret-key path traversal. */
|
|
19
|
+
const PROFILE_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
|
|
20
|
+
/** Methods we permit through the signed proxies. */
|
|
21
|
+
const ALLOWED_METHODS = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'];
|
|
22
|
+
/**
|
|
23
|
+
* Hop-by-hop headers — per RFC 7230 these are connection-scoped and must not
|
|
24
|
+
* be propagated upstream / downstream. Lowercase for comparison.
|
|
25
|
+
*/
|
|
26
|
+
const HOP_BY_HOP = new Set([
|
|
27
|
+
'connection',
|
|
28
|
+
'keep-alive',
|
|
29
|
+
'proxy-authenticate',
|
|
30
|
+
'proxy-authorization',
|
|
31
|
+
'te',
|
|
32
|
+
'trailer',
|
|
33
|
+
'transfer-encoding',
|
|
34
|
+
'upgrade',
|
|
35
|
+
]);
|
|
36
|
+
/** Adobe da.live API origin. Hard-coded — clients send only the path component. */
|
|
37
|
+
const DA_ORIGIN = 'https://admin.da.live';
|
|
38
|
+
class ProfileNotConfiguredError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'ProfileNotConfiguredError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ----------------- helpers -----------------
|
|
45
|
+
function decodeBase64(b64) {
|
|
46
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
47
|
+
}
|
|
48
|
+
function encodeBase64(bytes) {
|
|
49
|
+
return Buffer.from(bytes).toString('base64');
|
|
50
|
+
}
|
|
51
|
+
function isAllowedMethod(m) {
|
|
52
|
+
return typeof m === 'string' && ALLOWED_METHODS.includes(m);
|
|
53
|
+
}
|
|
54
|
+
function readSecretValue(store, key) {
|
|
55
|
+
const secret = store.get(key);
|
|
56
|
+
return secret?.value;
|
|
57
|
+
}
|
|
58
|
+
function resolveS3Profile(name, store) {
|
|
59
|
+
const accessKeyId = readSecretValue(store, `s3.${name}.access_key_id`);
|
|
60
|
+
const secretAccessKey = readSecretValue(store, `s3.${name}.secret_access_key`);
|
|
61
|
+
if (!accessKeyId) {
|
|
62
|
+
throw new ProfileNotConfiguredError(`profile '${name}' missing required field 'access_key_id'. ` +
|
|
63
|
+
`Set it via: secret set s3.${name}.access_key_id <value>`);
|
|
64
|
+
}
|
|
65
|
+
if (!secretAccessKey) {
|
|
66
|
+
throw new ProfileNotConfiguredError(`profile '${name}' missing required field 'secret_access_key'. ` +
|
|
67
|
+
`Set it via: secret set s3.${name}.secret_access_key <value>`);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
accessKeyId,
|
|
71
|
+
secretAccessKey,
|
|
72
|
+
sessionToken: readSecretValue(store, `s3.${name}.session_token`),
|
|
73
|
+
region: readSecretValue(store, `s3.${name}.region`) ?? 'us-east-1',
|
|
74
|
+
endpoint: readSecretValue(store, `s3.${name}.endpoint`),
|
|
75
|
+
pathStyle: readSecretValue(store, `s3.${name}.path_style`) === 'true',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Build the S3 URL based on profile addressing style. */
|
|
79
|
+
function buildS3Url(profile, bucket, key, query) {
|
|
80
|
+
// Determine host from explicit endpoint (R2/MinIO) or AWS region default.
|
|
81
|
+
let host;
|
|
82
|
+
if (profile.endpoint) {
|
|
83
|
+
try {
|
|
84
|
+
host = new URL(profile.endpoint).host;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new Error(`profile endpoint is not a valid URL: ${profile.endpoint}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
host = `s3.${profile.region}.amazonaws.com`;
|
|
92
|
+
}
|
|
93
|
+
// Encode key segment-by-segment so '/' is preserved.
|
|
94
|
+
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
|
|
95
|
+
const encodedBucket = encodeURIComponent(bucket);
|
|
96
|
+
const pathPart = profile.pathStyle ? `${encodedBucket}/${encodedKey}` : encodedKey;
|
|
97
|
+
const hostPart = profile.pathStyle ? host : `${encodedBucket}.${host}`;
|
|
98
|
+
const url = new URL(`https://${hostPart}/${pathPart}`);
|
|
99
|
+
if (query) {
|
|
100
|
+
for (const [k, v] of Object.entries(query)) {
|
|
101
|
+
url.searchParams.set(k, v);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return url;
|
|
105
|
+
}
|
|
106
|
+
/** Copy upstream response headers, dropping hop-by-hop entries. */
|
|
107
|
+
function passthroughHeaders(upstream) {
|
|
108
|
+
const out = {};
|
|
109
|
+
upstream.headers.forEach((value, key) => {
|
|
110
|
+
if (!HOP_BY_HOP.has(key.toLowerCase())) {
|
|
111
|
+
out[key] = value;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
// ----------------- handlers -----------------
|
|
117
|
+
/**
|
|
118
|
+
* Handle a `POST /api/s3-sign-and-forward` request. Validates the envelope,
|
|
119
|
+
* resolves credentials, signs, forwards, returns a JSON envelope.
|
|
120
|
+
*
|
|
121
|
+
* Errors in setup return 400 with a structured `{ ok: false, error, errorCode }`.
|
|
122
|
+
* Network errors against the upstream return 502.
|
|
123
|
+
*/
|
|
124
|
+
export async function handleS3SignAndForward(req, res, secretStore) {
|
|
125
|
+
const env = req.body;
|
|
126
|
+
if (typeof env?.profile !== 'string' ||
|
|
127
|
+
env.profile.length === 0 ||
|
|
128
|
+
!PROFILE_NAME_REGEX.test(env.profile)) {
|
|
129
|
+
res.status(400).json({
|
|
130
|
+
ok: false,
|
|
131
|
+
error: 'invalid profile name (allowed: alphanumeric, dot, underscore, hyphen)',
|
|
132
|
+
errorCode: 'invalid_profile',
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!isAllowedMethod(env.method)) {
|
|
137
|
+
res.status(400).json({ ok: false, error: 'invalid method', errorCode: 'invalid_request' });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (typeof env.bucket !== 'string' || env.bucket.length === 0) {
|
|
141
|
+
res.status(400).json({ ok: false, error: 'invalid bucket', errorCode: 'invalid_request' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (typeof env.key !== 'string') {
|
|
145
|
+
res.status(400).json({ ok: false, error: 'invalid key', errorCode: 'invalid_request' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let profile;
|
|
149
|
+
try {
|
|
150
|
+
profile = resolveS3Profile(env.profile, secretStore);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err instanceof ProfileNotConfiguredError) {
|
|
154
|
+
res.status(400).json({
|
|
155
|
+
ok: false,
|
|
156
|
+
error: err.message,
|
|
157
|
+
errorCode: 'profile_not_configured',
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
let url;
|
|
164
|
+
try {
|
|
165
|
+
url = buildS3Url(profile, env.bucket, env.key, env.query);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
res.status(400).json({
|
|
169
|
+
ok: false,
|
|
170
|
+
error: err instanceof Error ? err.message : 'failed to build URL',
|
|
171
|
+
errorCode: 'invalid_request',
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const body = typeof env.bodyBase64 === 'string' && env.bodyBase64.length > 0
|
|
176
|
+
? decodeBase64(env.bodyBase64)
|
|
177
|
+
: undefined;
|
|
178
|
+
const signed = await signSigV4({
|
|
179
|
+
method: env.method,
|
|
180
|
+
url,
|
|
181
|
+
headers: { ...(env.headers ?? {}), Host: url.host },
|
|
182
|
+
body,
|
|
183
|
+
}, {
|
|
184
|
+
accessKeyId: profile.accessKeyId,
|
|
185
|
+
secretAccessKey: profile.secretAccessKey,
|
|
186
|
+
sessionToken: profile.sessionToken,
|
|
187
|
+
}, profile.region, 's3');
|
|
188
|
+
let upstream;
|
|
189
|
+
try {
|
|
190
|
+
upstream = await fetch(url.toString(), {
|
|
191
|
+
method: signed.method,
|
|
192
|
+
headers: signed.headers,
|
|
193
|
+
// Cast: TS 6 narrows Uint8Array<ArrayBufferLike> too aggressively for
|
|
194
|
+
// BodyInit; runtime accepts the bytes fine.
|
|
195
|
+
body: signed.body,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
res.status(502).json({
|
|
200
|
+
ok: false,
|
|
201
|
+
error: `S3 fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
202
|
+
errorCode: 'fetch_failed',
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const upstreamBody = new Uint8Array(await upstream.arrayBuffer());
|
|
207
|
+
res.json({
|
|
208
|
+
ok: true,
|
|
209
|
+
status: upstream.status,
|
|
210
|
+
headers: passthroughHeaders(upstream),
|
|
211
|
+
bodyBase64: encodeBase64(upstreamBody),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Handle a `POST /api/da-sign-and-forward` request. Attaches the IMS bearer
|
|
216
|
+
* token (passed transiently in the envelope), forwards to da.live, returns
|
|
217
|
+
* a JSON envelope.
|
|
218
|
+
*
|
|
219
|
+
* v1: the IMS token comes from the browser at request time. The browser
|
|
220
|
+
* already holds the token via the existing Adobe LLM provider OAuth flow;
|
|
221
|
+
* routing through the server gives architectural symmetry with S3 and a
|
|
222
|
+
* place to tighten the threat model in v2 (server-side OAuth).
|
|
223
|
+
*/
|
|
224
|
+
export async function handleDaSignAndForward(req, res) {
|
|
225
|
+
const env = req.body;
|
|
226
|
+
if (typeof env?.imsToken !== 'string' || env.imsToken.length === 0) {
|
|
227
|
+
res.status(400).json({
|
|
228
|
+
ok: false,
|
|
229
|
+
error: 'imsToken is required',
|
|
230
|
+
errorCode: 'invalid_request',
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!isAllowedMethod(env.method)) {
|
|
235
|
+
res.status(400).json({ ok: false, error: 'invalid method', errorCode: 'invalid_request' });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (typeof env.path !== 'string' || !env.path.startsWith('/')) {
|
|
239
|
+
res.status(400).json({
|
|
240
|
+
ok: false,
|
|
241
|
+
error: 'path must be a string starting with /',
|
|
242
|
+
errorCode: 'invalid_request',
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
let url;
|
|
247
|
+
try {
|
|
248
|
+
url = new URL(DA_ORIGIN + env.path);
|
|
249
|
+
if (env.query) {
|
|
250
|
+
for (const [k, v] of Object.entries(env.query)) {
|
|
251
|
+
url.searchParams.set(k, v);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
res.status(400).json({
|
|
257
|
+
ok: false,
|
|
258
|
+
error: err instanceof Error ? err.message : 'failed to build URL',
|
|
259
|
+
errorCode: 'invalid_request',
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const body = typeof env.bodyBase64 === 'string' && env.bodyBase64.length > 0
|
|
264
|
+
? decodeBase64(env.bodyBase64)
|
|
265
|
+
: undefined;
|
|
266
|
+
let upstream;
|
|
267
|
+
try {
|
|
268
|
+
upstream = await fetch(url.toString(), {
|
|
269
|
+
method: env.method,
|
|
270
|
+
headers: {
|
|
271
|
+
...(env.headers ?? {}),
|
|
272
|
+
Authorization: `Bearer ${env.imsToken}`,
|
|
273
|
+
},
|
|
274
|
+
// Cast: TS 6 narrows Uint8Array<ArrayBufferLike> too aggressively for
|
|
275
|
+
// BodyInit; runtime accepts the bytes fine.
|
|
276
|
+
body: body,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
res.status(502).json({
|
|
281
|
+
ok: false,
|
|
282
|
+
error: `DA fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
283
|
+
errorCode: 'fetch_failed',
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const upstreamBody = new Uint8Array(await upstream.arrayBuffer());
|
|
288
|
+
res.json({
|
|
289
|
+
ok: true,
|
|
290
|
+
status: upstream.status,
|
|
291
|
+
headers: passthroughHeaders(upstream),
|
|
292
|
+
bodyBase64: encodeBase64(upstreamBody),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SigV4 v4 signing — node-server copy.
|
|
3
|
+
*
|
|
4
|
+
* **Mirrored from `packages/webapp/src/fs/mount/signing-s3.ts`.** Both files
|
|
5
|
+
* are byte-for-byte equivalent in behavior and must stay in sync. The reason
|
|
6
|
+
* for two copies is that `tsconfig.cli.json` pins `rootDir` to
|
|
7
|
+
* `packages/node-server/src`, so cross-importing the webapp source under
|
|
8
|
+
* NodeNext resolution is rejected by the compiler. Sharing via a workspace
|
|
9
|
+
* package is a larger change than this PR's scope.
|
|
10
|
+
*
|
|
11
|
+
* Drift between the two copies is caught by both test suites running the
|
|
12
|
+
* same canonical AWS test vectors:
|
|
13
|
+
* - `packages/webapp/tests/fs/mount/signing-s3.test.ts`
|
|
14
|
+
* - `packages/node-server/tests/secrets/signing-s3.test.ts`
|
|
15
|
+
*
|
|
16
|
+
* If you change one, change the other and verify both test suites pass.
|
|
17
|
+
*
|
|
18
|
+
* Pure function — given a request + credentials + region + service + clock,
|
|
19
|
+
* produces the same request with an `Authorization` header attached. Uses
|
|
20
|
+
* Web Crypto (`crypto.subtle`) which works in browsers, extension service
|
|
21
|
+
* workers, and Node 22+ (where it lives on `globalThis.crypto`).
|
|
22
|
+
*/
|
|
23
|
+
export interface SigV4Request {
|
|
24
|
+
method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'HEAD';
|
|
25
|
+
url: URL;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
body?: Uint8Array;
|
|
28
|
+
}
|
|
29
|
+
export interface SigV4Credentials {
|
|
30
|
+
accessKeyId: string;
|
|
31
|
+
secretAccessKey: string;
|
|
32
|
+
sessionToken?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function signSigV4(req: SigV4Request, creds: SigV4Credentials, region: string, service?: string, now?: Date): Promise<SigV4Request>;
|