1import {
2 ArchiveError,
3 ArchiveObjectHash,
4 AuthSessionId,
5 EnvironmentId,
6 ThreadId,
7 type AssetCreateUrlResult,
8 type AssetResource,
9} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Redacted from "effect/Redacted";
16import * as Schema from "effect/Schema";
17import * as ArchiveCatalog from "./ArchiveCatalog.ts";
18import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
19import * as SessionStore from "../auth/SessionStore.ts";
20import {
21 base64UrlDecodeUtf8,
22 base64UrlEncode,
23 signPayload,
24 timingSafeEqualBase64Url,
25} from "../auth/utils.ts";
26
27const Claims = Schema.Struct({
28 version: Schema.Literal(1),
29 environmentId: EnvironmentId,
30 threadId: ThreadId,
31 manifestHash: ArchiveObjectHash,
32 hash: ArchiveObjectHash,
33 sessionId: AuthSessionId,
34 expiresAt: Schema.Number,
35 fileName: Schema.String,
36 mimeType: Schema.String,
37 download: Schema.Boolean,
38 renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")),
39});
40const encodeClaims = Schema.encodeSync(Schema.fromJsonString(Claims));
41const decodeClaims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims));
42const storageError = (cause: unknown) =>
43 new ArchiveError({ reason: "storage", cause: Redacted.make(cause) });
44
45export class ArchiveAssets extends Context.Service<
46 ArchiveAssets,
47 {
48 readonly createUrl: (
49 environmentId: EnvironmentId,
50 resource: AssetResource,
51 sessionId: AuthSessionId,
52 ) => Effect.Effect<AssetCreateUrlResult, ArchiveError>;
53 readonly resolve: (token: string) => Effect.Effect<
54 Option.Option<{
55 readonly path: string;
56 readonly fileName: string;
57 readonly mimeType: string;
58 readonly download: boolean;
59 readonly renderIntent?: "mcp-app";
60 }>,
61 ArchiveError
62 >;
63 }
64>()("t3/archive/ArchiveAssets") {}
65
66export const layer = Layer.effect(
67 ArchiveAssets,
68 Effect.gen(function* () {
69 const catalog = yield* ArchiveCatalog.ArchiveCatalog;
70 const sessions = yield* SessionStore.SessionStore;
71 const secret = yield* (yield* ServerSecretStore.ServerSecretStore)
72 .getOrCreateRandom("archive-asset-signing-key", 32)
73 .pipe(Effect.mapError(storageError));
74 return ArchiveAssets.of({
75 createUrl: (environmentId, resource, sessionId) =>
76 Effect.gen(function* () {
77 const asset = yield* catalog.asset(environmentId, resource);
78 const expiresAt = (yield* Clock.currentTimeMillis) + 5 * 60_000;
79 const payload = base64UrlEncode(
80 encodeClaims({ version: 1, environmentId, ...asset, sessionId, expiresAt }),
81 );
82 const token = `${payload}.${signPayload(payload, secret)}`;
83 return {
84 relativeUrl: `/api/archive/assets/${token}/${encodeURIComponent(asset.fileName)}`,
85 expiresAt,
86 };
87 }),
88 resolve: (token) =>
89 Effect.gen(function* () {
90 const separator = token.indexOf(".");
91 if (separator <= 0) return Option.none();
92 const payload = token.slice(0, separator);
93 if (!timingSafeEqualBase64Url(token.slice(separator + 1), signPayload(payload, secret)))
94 return Option.none();
95 const decoded = yield* Effect.try(() => decodeClaims(base64UrlDecodeUtf8(payload))).pipe(
96 Effect.orElseSucceed(() => Option.none()),
97 );
98 if (Option.isNone(decoded)) return Option.none();
99 const claims = decoded.value;
100 if (claims.expiresAt <= (yield* Clock.currentTimeMillis)) return Option.none();
101 const active = yield* sessions.verifyActive(claims.sessionId).pipe(
102 Effect.as(true),
103 Effect.orElseSucceed(() => false),
104 );
105 if (!active) return Option.none();
106 const file = yield* catalog
107 .openAsset(claims.environmentId, claims.threadId, claims.manifestHash, claims.hash)
108 .pipe(
109 Effect.asSome,
110 Effect.catchTags({
111 ArchiveError: (error) =>
112 error.reason === "unavailable" || error.reason === "changed"
113 ? Effect.succeedNone
114 : Effect.fail(error),
115 }),
116 );
117 if (Option.isNone(file)) return Option.none();
118 return Option.some({
119 path: file.value,
120 fileName: claims.fileName,
121 mimeType: claims.mimeType,
122 download: claims.download,
123 ...(claims.renderIntent ? { renderIntent: claims.renderIntent } : {}),
124 });
125 }),
126 });
127 }),
128);