@dwk/remotestorage 0.1.0-beta.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 +15 -0
- package/README.md +131 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +108 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +132 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +67 -0
- package/dist/config.js.map +1 -0
- package/dist/cors.d.ts +23 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +56 -0
- package/dist/cors.js.map +1 -0
- package/dist/discovery.d.ts +31 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +35 -0
- package/dist/discovery.js.map +1 -0
- package/dist/encoding.d.ts +11 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +21 -0
- package/dist/encoding.js.map +1 -0
- package/dist/folder.d.ts +78 -0
- package/dist/folder.d.ts.map +1 -0
- package/dist/folder.js +0 -0
- package/dist/folder.js.map +1 -0
- package/dist/gc.d.ts +23 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +34 -0
- package/dist/gc.js.map +1 -0
- package/dist/handler.d.ts +21 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +166 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +35 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +119 -0
- package/dist/jwt.js.map +1 -0
- package/dist/log.d.ts +29 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +27 -0
- package/dist/log.js.map +1 -0
- package/dist/scope.d.ts +54 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +83 -0
- package/dist/scope.js.map +1 -0
- package/dist/storage.d.ts +22 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +313 -0
- package/dist/storage.js.map +1 -0
- package/package.json +50 -0
- package/src/auth.ts +146 -0
- package/src/config.ts +197 -0
- package/src/cors.ts +68 -0
- package/src/discovery.ts +48 -0
- package/src/encoding.ts +22 -0
- package/src/folder.ts +0 -0
- package/src/gc.ts +50 -0
- package/src/handler.ts +225 -0
- package/src/index.ts +84 -0
- package/src/jwt.ts +155 -0
- package/src/log.ts +31 -0
- package/src/scope.ts +99 -0
- package/src/storage.ts +398 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration, the declared Cloudflare `Env` fragment, and config resolution
|
|
3
|
+
* for `@dwk/remotestorage`.
|
|
4
|
+
*
|
|
5
|
+
* Per the composition contract the package never reads the global environment
|
|
6
|
+
* directly: all tunables (base URL, token issuer/JWKS, accepted audience,
|
|
7
|
+
* offload threshold, GC window, path mapping) are passed into
|
|
8
|
+
* {@link createRemoteStorage} / {@link createRemoteStorageGc}, so a vault can be
|
|
9
|
+
* instantiated multiple times and unit-tested in isolation. The Cloudflare
|
|
10
|
+
* bindings — the per-account Durable Object namespace and the R2 blob bucket —
|
|
11
|
+
* are the only runtime coupling, and a missing one fails loudly at startup.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
|
|
15
|
+
|
|
16
|
+
import type { RemoteStorageAuth } from "./auth";
|
|
17
|
+
import type { RemoteStorageObject } from "./storage";
|
|
18
|
+
|
|
19
|
+
/** Cloudflare bindings required by the handler and the per-account Durable Object. */
|
|
20
|
+
export interface RemoteStorageEnv {
|
|
21
|
+
/** Durable Object namespace for the per-account class ({@link RemoteStorageObject}). */
|
|
22
|
+
readonly STORAGE: DurableObjectNamespace<RemoteStorageObject>;
|
|
23
|
+
/** R2 bucket holding document bodies (MAY be shared with `@dwk/solid-pod`). */
|
|
24
|
+
readonly BLOBS: R2Bucket;
|
|
25
|
+
/**
|
|
26
|
+
* Shared D1 database tracking orphaned blob keys for the out-of-band GC cron.
|
|
27
|
+
* Optional: when bound, the DO forwards its transactional orphan outbox here
|
|
28
|
+
* after writes so {@link createRemoteStorageGc} can reclaim them without ever
|
|
29
|
+
* waking a Durable Object.
|
|
30
|
+
*/
|
|
31
|
+
readonly GC_DB?: D1Database;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Cloudflare bindings required by the out-of-band R2 garbage-collection cron. */
|
|
35
|
+
export interface RemoteStorageGcEnv {
|
|
36
|
+
/** R2 bucket holding document bodies. */
|
|
37
|
+
readonly BLOBS: R2Bucket;
|
|
38
|
+
/** Shared D1 database tracking orphaned blob keys (see `@dwk/store`). */
|
|
39
|
+
readonly GC_DB: D1Database;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The account and account-relative storage path parsed from a request URL. */
|
|
43
|
+
export interface ParsedPath {
|
|
44
|
+
/** Stable account identifier; keys the per-account Durable Object. */
|
|
45
|
+
readonly account: string;
|
|
46
|
+
/** Account-relative storage path, percent-encoded, always starting with `/`. */
|
|
47
|
+
readonly path: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Configuration passed to {@link createRemoteStorage}. */
|
|
51
|
+
export interface RemoteStorageConfig {
|
|
52
|
+
/**
|
|
53
|
+
* The storage server's base URL, e.g. `https://storage.example`. Used for
|
|
54
|
+
* discovery metadata and as a default token audience. No trailing slash.
|
|
55
|
+
*/
|
|
56
|
+
readonly baseUrl: string;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Accepted access-token issuer (`iss`). When set, a token whose `iss` differs
|
|
60
|
+
* is rejected. Ignored when a custom {@link authenticate} hook is supplied.
|
|
61
|
+
*/
|
|
62
|
+
readonly issuer?: string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Accepted token audience(s) (`aud`). When set, a token is accepted only when
|
|
66
|
+
* its `aud` intersects this set; when unset the audience check is skipped
|
|
67
|
+
* (plain OAuth bearer tokens often omit `aud`).
|
|
68
|
+
*/
|
|
69
|
+
readonly audience?: string | readonly string[];
|
|
70
|
+
|
|
71
|
+
/** Static JWK verification keys for the issuer (hermetic alternative to {@link jwksUri}). */
|
|
72
|
+
readonly jwks?: readonly JsonWebKey[];
|
|
73
|
+
|
|
74
|
+
/** Issuer JWKS endpoint; fetched and cached when {@link jwks} is not given. */
|
|
75
|
+
readonly jwksUri?: string;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bodies larger than this (bytes) are offloaded to R2 as opaque blobs instead
|
|
79
|
+
* of the DO SQLite cell. Defaults to the ~2 MB DO-cell ceiling in `@dwk/store`.
|
|
80
|
+
*/
|
|
81
|
+
readonly maxInlineBytes?: number;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* GC safety window (ms) advertised to the cron handler; an orphaned R2 object
|
|
85
|
+
* is reclaimed only once it is older than this. MUST be ≥ the maximum write
|
|
86
|
+
* duration. Defaults to five minutes.
|
|
87
|
+
*/
|
|
88
|
+
readonly gcSafetyWindowMs?: number;
|
|
89
|
+
|
|
90
|
+
/** Injectable clock (epoch ms) for deterministic tests. Defaults to `Date.now`. */
|
|
91
|
+
readonly now?: () => number;
|
|
92
|
+
|
|
93
|
+
/** `fetch` implementation used to resolve {@link jwksUri}; defaults to global `fetch`. */
|
|
94
|
+
readonly fetch?: typeof fetch;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Override the bearer-token verification entirely. When provided, the handler
|
|
98
|
+
* calls this instead of the built-in JWKS verifier — useful for tests and for
|
|
99
|
+
* token formats the default verifier does not cover (e.g. opaque tokens
|
|
100
|
+
* resolved via RFC 7662 introspection). Returning `null` means "no/invalid
|
|
101
|
+
* credentials"; the request then proceeds unauthenticated (only `/public/`
|
|
102
|
+
* document reads succeed).
|
|
103
|
+
*/
|
|
104
|
+
readonly authenticate?: (
|
|
105
|
+
request: Request,
|
|
106
|
+
) => Promise<RemoteStorageAuth | null> | RemoteStorageAuth | null;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Map a request `pathname` to an {@link ParsedPath}. Defaults to treating the
|
|
110
|
+
* first path segment as the account and the remainder (with leading slash) as
|
|
111
|
+
* the account-relative storage path: `/alice/documents/x` → account `alice`,
|
|
112
|
+
* path `/documents/x`; `/alice` or `/alice/` → account `alice`, path `/`.
|
|
113
|
+
* Return `null` to 404 a request that names no account.
|
|
114
|
+
*/
|
|
115
|
+
readonly parsePath?: (pathname: string) => ParsedPath | null;
|
|
116
|
+
|
|
117
|
+
/** Logger for auth/authz events; defaults to a no-op (see `@dwk/log`). */
|
|
118
|
+
readonly logger?: Logger;
|
|
119
|
+
/** Metrics sink for the same events; defaults to a no-op. */
|
|
120
|
+
readonly metrics?: Metrics;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fully-resolved configuration with defaults applied. */
|
|
124
|
+
export interface ResolvedConfig {
|
|
125
|
+
readonly baseUrl: string;
|
|
126
|
+
readonly issuer?: string;
|
|
127
|
+
readonly audience: readonly string[];
|
|
128
|
+
readonly jwks?: readonly JsonWebKey[];
|
|
129
|
+
readonly jwksUri?: string;
|
|
130
|
+
readonly maxInlineBytes?: number;
|
|
131
|
+
readonly gcSafetyWindowMs: number;
|
|
132
|
+
readonly now: () => number;
|
|
133
|
+
readonly fetch: typeof fetch;
|
|
134
|
+
readonly authenticate?: RemoteStorageConfig["authenticate"];
|
|
135
|
+
readonly parsePath: (pathname: string) => ParsedPath | null;
|
|
136
|
+
readonly logger: Logger;
|
|
137
|
+
readonly metrics: Metrics;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Default GC safety window: five minutes, comfortably above any write. */
|
|
141
|
+
const DEFAULT_GC_SAFETY_WINDOW_MS = 5 * 60 * 1000;
|
|
142
|
+
|
|
143
|
+
/** Internal header the trusted front door uses to hand config to the DO. */
|
|
144
|
+
export const INTERNAL_HEADERS = {
|
|
145
|
+
/** JSON-encoded subset of config the DO needs (offload threshold). */
|
|
146
|
+
config: "x-remotestorage-config",
|
|
147
|
+
} as const;
|
|
148
|
+
|
|
149
|
+
/** Strip the trailing slash from a base URL so joins are unambiguous. */
|
|
150
|
+
function normalizeBaseUrl(baseUrl: string): string {
|
|
151
|
+
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Default {@link RemoteStorageConfig.parsePath}: first segment is the account,
|
|
156
|
+
* the rest (with leading slash) is the storage path. Returns `null` when the
|
|
157
|
+
* path names no account (e.g. `/`).
|
|
158
|
+
*/
|
|
159
|
+
export function defaultParsePath(pathname: string): ParsedPath | null {
|
|
160
|
+
const match = /^\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
161
|
+
if (!match) return null;
|
|
162
|
+
const account = decodeURIComponent(match[1] as string);
|
|
163
|
+
// Keep the storage path percent-encoded: decoding `%2F` would conflate it
|
|
164
|
+
// with a real separator and corrupt store keys.
|
|
165
|
+
const path = match[2] ?? "/";
|
|
166
|
+
return { account, path };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Apply defaults and derived values to raw {@link RemoteStorageConfig}. */
|
|
170
|
+
export function resolveConfig(config: RemoteStorageConfig): ResolvedConfig {
|
|
171
|
+
if (!config.baseUrl) {
|
|
172
|
+
throw new Error("@dwk/remotestorage: `baseUrl` is required");
|
|
173
|
+
}
|
|
174
|
+
const baseUrl = normalizeBaseUrl(config.baseUrl);
|
|
175
|
+
const audience =
|
|
176
|
+
config.audience === undefined
|
|
177
|
+
? []
|
|
178
|
+
: typeof config.audience === "string"
|
|
179
|
+
? [config.audience]
|
|
180
|
+
: [...config.audience];
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
baseUrl,
|
|
184
|
+
issuer: config.issuer,
|
|
185
|
+
audience,
|
|
186
|
+
jwks: config.jwks,
|
|
187
|
+
jwksUri: config.jwksUri,
|
|
188
|
+
maxInlineBytes: config.maxInlineBytes,
|
|
189
|
+
gcSafetyWindowMs: config.gcSafetyWindowMs ?? DEFAULT_GC_SAFETY_WINDOW_MS,
|
|
190
|
+
now: config.now ?? (() => Date.now()),
|
|
191
|
+
fetch: config.fetch ?? fetch,
|
|
192
|
+
authenticate: config.authenticate,
|
|
193
|
+
parsePath: config.parsePath ?? defaultParsePath,
|
|
194
|
+
logger: config.logger ?? noopLogger,
|
|
195
|
+
metrics: config.metrics ?? noopMetrics,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/cors.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissive CORS for browser apps on other origins (draft §6). remoteStorage
|
|
3
|
+
* is built for "no-backend" web apps that run on an origin different from the
|
|
4
|
+
* storage server, so every response — including errors and the preflight — must
|
|
5
|
+
* carry CORS headers or the browser hides the result from the app.
|
|
6
|
+
*
|
|
7
|
+
* Pure and runtime-free: it maps a request `Origin` to the header set. Bearer
|
|
8
|
+
* tokens travel in the `Authorization` header (not cookies), so credentialed
|
|
9
|
+
* CORS is unnecessary and `*` is a safe default; when a concrete `Origin` is
|
|
10
|
+
* present it is echoed (with `Vary: Origin`) so caches stay correct.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Methods the storage API exposes, advertised on the preflight. */
|
|
14
|
+
export const ALLOWED_METHODS = "GET, HEAD, PUT, DELETE, OPTIONS";
|
|
15
|
+
|
|
16
|
+
/** Request headers a browser app may send (preflight `Access-Control-Allow-Headers`). */
|
|
17
|
+
const ALLOWED_HEADERS =
|
|
18
|
+
"Authorization, Content-Type, Content-Length, If-Match, If-None-Match, Origin, X-Requested-With";
|
|
19
|
+
|
|
20
|
+
/** Response headers a browser app may read (`Access-Control-Expose-Headers`). */
|
|
21
|
+
const EXPOSED_HEADERS = "ETag, Content-Type, Content-Length, Location";
|
|
22
|
+
|
|
23
|
+
/** How long (seconds) a browser may cache the preflight result. */
|
|
24
|
+
const MAX_AGE = "86400";
|
|
25
|
+
|
|
26
|
+
/** Build the CORS header set for a request with the given `Origin` (may be null). */
|
|
27
|
+
export function corsHeaders(
|
|
28
|
+
requestOrigin: string | null,
|
|
29
|
+
): Record<string, string> {
|
|
30
|
+
const headers: Record<string, string> = {
|
|
31
|
+
"access-control-allow-origin": requestOrigin ?? "*",
|
|
32
|
+
"access-control-expose-headers": EXPOSED_HEADERS,
|
|
33
|
+
vary: "Origin",
|
|
34
|
+
};
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The preflight (`OPTIONS`) response, answered at the edge without a store hit. */
|
|
39
|
+
export function preflightResponse(requestOrigin: string | null): Response {
|
|
40
|
+
return new Response(null, {
|
|
41
|
+
status: 204,
|
|
42
|
+
headers: {
|
|
43
|
+
...corsHeaders(requestOrigin),
|
|
44
|
+
"access-control-allow-methods": ALLOWED_METHODS,
|
|
45
|
+
"access-control-allow-headers": ALLOWED_HEADERS,
|
|
46
|
+
"access-control-max-age": MAX_AGE,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return `response` with the CORS headers merged in. The body and status are
|
|
53
|
+
* preserved; existing headers win only for names CORS does not own.
|
|
54
|
+
*/
|
|
55
|
+
export function withCors(
|
|
56
|
+
response: Response,
|
|
57
|
+
requestOrigin: string | null,
|
|
58
|
+
): Response {
|
|
59
|
+
const headers = new Headers(response.headers);
|
|
60
|
+
for (const [name, value] of Object.entries(corsHeaders(requestOrigin))) {
|
|
61
|
+
headers.set(name, value);
|
|
62
|
+
}
|
|
63
|
+
return new Response(response.body, {
|
|
64
|
+
status: response.status,
|
|
65
|
+
statusText: response.statusText,
|
|
66
|
+
headers,
|
|
67
|
+
});
|
|
68
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remoteStorage discovery (draft §10): the WebFinger link advertising a user's
|
|
3
|
+
* storage root and the OAuth endpoint a connecting app uses.
|
|
4
|
+
*
|
|
5
|
+
* A no-backend app is given only a user address (`user@host`); it discovers the
|
|
6
|
+
* storage root by querying WebFinger for that account and reading the
|
|
7
|
+
* remoteStorage link. This module builds that link as a plain
|
|
8
|
+
* [`@dwk/webfinger`](../webfinger) `Link` so a deployment can drop it into its
|
|
9
|
+
* existing WebFinger resource record rather than standing up a second endpoint.
|
|
10
|
+
* Pure data — no Workers runtime.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Link } from "@dwk/webfinger";
|
|
14
|
+
|
|
15
|
+
/** The remoteStorage WebFinger link relation (draft §10). */
|
|
16
|
+
export const REMOTESTORAGE_REL =
|
|
17
|
+
"http://tools.ietf.org/id/draft-dejong-remotestorage";
|
|
18
|
+
|
|
19
|
+
/** The remoteStorage draft version advertised in the link properties. */
|
|
20
|
+
export const REMOTESTORAGE_VERSION = "draft-dejong-remotestorage-22";
|
|
21
|
+
|
|
22
|
+
/** Inputs for {@link remoteStorageLink}. */
|
|
23
|
+
export interface RemoteStorageLinkConfig {
|
|
24
|
+
/** The user's storage root URL (the `href` apps GET/PUT/DELETE under). */
|
|
25
|
+
readonly storageRoot: string;
|
|
26
|
+
/** The OAuth authorization endpoint (RFC 6749 §4.2 implicit grant). */
|
|
27
|
+
readonly authEndpoint: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the WebFinger remoteStorage `Link` for one account. The `properties`
|
|
32
|
+
* carry the spec version, the OAuth authorization URL, and the capability flags
|
|
33
|
+
* remoteStorage clients read (`Bearer` auth, `Content-Range` support); a `null`
|
|
34
|
+
* property value means "advertised as absent", per RFC 7033.
|
|
35
|
+
*/
|
|
36
|
+
export function remoteStorageLink(config: RemoteStorageLinkConfig): Link {
|
|
37
|
+
return {
|
|
38
|
+
rel: REMOTESTORAGE_REL,
|
|
39
|
+
href: config.storageRoot,
|
|
40
|
+
properties: {
|
|
41
|
+
"http://remotestorage.io/spec/version": REMOTESTORAGE_VERSION,
|
|
42
|
+
"http://tools.ietf.org/html/rfc6749#section-4.2": config.authEndpoint,
|
|
43
|
+
"http://tools.ietf.org/html/rfc6750#section-2.3": "false",
|
|
44
|
+
"http://tools.ietf.org/html/rfc7233": "GET",
|
|
45
|
+
"http://remotestorage.io/spec/web-authoring": null,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64url + UTF-8 helpers for the OAuth bearer-token decode path.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free and runtime-agnostic (`atob` / `btoa` / Web `TextDecoder`
|
|
5
|
+
* only) so the surrounding modules unit-test without a Workers runtime.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Decode unpadded (or padded) base64url to bytes. */
|
|
9
|
+
export function base64urlToBytes(segment: string): Uint8Array {
|
|
10
|
+
const b64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
11
|
+
const padded =
|
|
12
|
+
b64.length % 4 === 0 ? b64 : b64 + "=".repeat(4 - (b64.length % 4));
|
|
13
|
+
const binary = atob(padded);
|
|
14
|
+
const bytes = new Uint8Array(binary.length);
|
|
15
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
16
|
+
return bytes;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Decode unpadded base64url to a UTF-8 string. */
|
|
20
|
+
export function base64urlToText(segment: string): string {
|
|
21
|
+
return new TextDecoder().decode(base64urlToBytes(segment));
|
|
22
|
+
}
|
package/src/folder.ts
ADDED
|
Binary file
|
package/src/gc.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The R2 garbage-collection cron handler.
|
|
3
|
+
*
|
|
4
|
+
* Each account's Durable Object forwards its orphaned blob keys (copy-on-write
|
|
5
|
+
* displacements and deletes) into a shared D1 table as it writes. This handler
|
|
6
|
+
* — wired to a Cloudflare cron trigger — reclaims those R2 objects once they are
|
|
7
|
+
* older than a safety window ≥ the maximum write duration, touching only D1 and
|
|
8
|
+
* R2 and never waking a Durable Object. The reclamation logic lives in
|
|
9
|
+
* `@dwk/store`; this is the thin endpoint wiring (identical in shape to
|
|
10
|
+
* `@dwk/solid-pod`'s GC, and able to share one `BLOBS`/`GC_DB` pair with it).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { collectGarbage, ensureGcSchema } from "@dwk/store";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
resolveConfig,
|
|
17
|
+
type RemoteStorageConfig,
|
|
18
|
+
type RemoteStorageGcEnv,
|
|
19
|
+
} from "./config";
|
|
20
|
+
|
|
21
|
+
/** A `scheduled`-compatible cron handler for R2 blob garbage collection. */
|
|
22
|
+
export type RemoteStorageGcHandler = (
|
|
23
|
+
event: ScheduledController,
|
|
24
|
+
env: RemoteStorageGcEnv,
|
|
25
|
+
ctx: ExecutionContext,
|
|
26
|
+
) => Promise<void>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create the cron handler that reclaims orphaned R2 blobs. Bind it to a
|
|
30
|
+
* `scheduled` trigger alongside the storage Worker; it shares the same `BLOBS`
|
|
31
|
+
* bucket and the `GC_DB` D1 database the DO forwards orphans into.
|
|
32
|
+
*
|
|
33
|
+
* Fails loudly when the required `BLOBS` / `GC_DB` bindings are missing.
|
|
34
|
+
*/
|
|
35
|
+
export function createRemoteStorageGc(
|
|
36
|
+
config: RemoteStorageConfig,
|
|
37
|
+
): RemoteStorageGcHandler {
|
|
38
|
+
const resolved = resolveConfig(config);
|
|
39
|
+
|
|
40
|
+
return async (_event, env, _ctx) => {
|
|
41
|
+
if (!env.BLOBS) {
|
|
42
|
+
throw new Error("@dwk/remotestorage: GC requires the `BLOBS` R2 binding");
|
|
43
|
+
}
|
|
44
|
+
if (!env.GC_DB) {
|
|
45
|
+
throw new Error("@dwk/remotestorage: GC requires the `GC_DB` D1 binding");
|
|
46
|
+
}
|
|
47
|
+
await ensureGcSchema(env.GC_DB);
|
|
48
|
+
await collectGarbage(env, { safetyWindowMs: resolved.gcSafetyWindowMs });
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateless remoteStorage front door.
|
|
3
|
+
*
|
|
4
|
+
* It answers CORS preflights at the edge, authenticates the OAuth bearer token
|
|
5
|
+
* (`auth.ts`), and enforces the per-module read/write **scope** model
|
|
6
|
+
* (`scope.ts`) — including the public-`/public/`-document exception — before
|
|
7
|
+
* forwarding an already-authorized request to the per-account Durable Object,
|
|
8
|
+
* which owns all storage consistency. One Durable Object per account (keyed by
|
|
9
|
+
* the parsed account id); the handler is mountable under any path prefix because
|
|
10
|
+
* it routes purely on the request URL via {@link RemoteStorageConfig.parsePath}.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { hostFromUrl, type LogFields } from "@dwk/log";
|
|
14
|
+
|
|
15
|
+
import { authenticate, type AuthResult } from "./auth";
|
|
16
|
+
import {
|
|
17
|
+
INTERNAL_HEADERS,
|
|
18
|
+
resolveConfig,
|
|
19
|
+
type RemoteStorageConfig,
|
|
20
|
+
type RemoteStorageEnv,
|
|
21
|
+
type ResolvedConfig,
|
|
22
|
+
} from "./config";
|
|
23
|
+
import { ALLOWED_METHODS, preflightResponse, withCors } from "./cors";
|
|
24
|
+
import { RemoteStorageLogEvent } from "./log";
|
|
25
|
+
import { authorizeScopes, isPublicDocument } from "./scope";
|
|
26
|
+
|
|
27
|
+
/** A `fetch`-compatible Worker handler. */
|
|
28
|
+
export type RemoteStorageHandler = (
|
|
29
|
+
request: Request,
|
|
30
|
+
env: RemoteStorageEnv,
|
|
31
|
+
ctx: ExecutionContext,
|
|
32
|
+
) => Promise<Response>;
|
|
33
|
+
|
|
34
|
+
/** Client headers safe to forward verbatim to the Durable Object. */
|
|
35
|
+
const FORWARDED_HEADERS = [
|
|
36
|
+
"content-type",
|
|
37
|
+
"content-length",
|
|
38
|
+
"if-match",
|
|
39
|
+
"if-none-match",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
/** Methods the storage API serves; anything else is `405`. */
|
|
43
|
+
const METHODS = new Set(["GET", "HEAD", "PUT", "DELETE"]);
|
|
44
|
+
|
|
45
|
+
/** Fail loudly if a required Cloudflare binding is missing (no silent degradation). */
|
|
46
|
+
function assertBindings(env: RemoteStorageEnv): void {
|
|
47
|
+
if (!env.STORAGE) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"@dwk/remotestorage: missing required Durable Object binding `STORAGE`",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (!env.BLOBS) {
|
|
53
|
+
throw new Error("@dwk/remotestorage: missing required R2 binding `BLOBS`");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build the internal DO request: clean account-relative URL + config header. */
|
|
58
|
+
function internalRequest(
|
|
59
|
+
request: Request,
|
|
60
|
+
storagePath: string,
|
|
61
|
+
config: ResolvedConfig,
|
|
62
|
+
): Request {
|
|
63
|
+
const headers = new Headers();
|
|
64
|
+
for (const name of FORWARDED_HEADERS) {
|
|
65
|
+
const value = request.headers.get(name);
|
|
66
|
+
if (value !== null) headers.set(name, value);
|
|
67
|
+
}
|
|
68
|
+
headers.set(
|
|
69
|
+
INTERNAL_HEADERS.config,
|
|
70
|
+
JSON.stringify(
|
|
71
|
+
config.maxInlineBytes !== undefined
|
|
72
|
+
? { maxInlineBytes: config.maxInlineBytes }
|
|
73
|
+
: {},
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const url = new URL(request.url);
|
|
78
|
+
url.pathname = storagePath;
|
|
79
|
+
url.search = "";
|
|
80
|
+
|
|
81
|
+
const method = request.method.toUpperCase();
|
|
82
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
83
|
+
return new Request(url.toString(), {
|
|
84
|
+
method: request.method,
|
|
85
|
+
headers,
|
|
86
|
+
...(hasBody ? { body: request.body } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Emit a structured event on both the logger and the metrics seam. */
|
|
91
|
+
function emit(
|
|
92
|
+
config: ResolvedConfig,
|
|
93
|
+
level: "info" | "warn",
|
|
94
|
+
event: string,
|
|
95
|
+
fields?: LogFields,
|
|
96
|
+
): void {
|
|
97
|
+
config.logger[level](event, fields);
|
|
98
|
+
config.metrics.count(event, fields);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A `401` with the Bearer challenge and a machine-readable failure reason. */
|
|
102
|
+
function unauthorized(reason: string): Response {
|
|
103
|
+
return new Response("Unauthorized", {
|
|
104
|
+
status: 401,
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "text/plain; charset=utf-8",
|
|
107
|
+
"www-authenticate": `Bearer realm="remotestorage", error="invalid_token", error_description="${reason}"`,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A `403` for an authenticated request whose scopes do not cover the target. */
|
|
113
|
+
function forbidden(): Response {
|
|
114
|
+
return new Response("Forbidden", {
|
|
115
|
+
status: 403,
|
|
116
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Decide whether a request is authorized, given the auth result, the storage
|
|
122
|
+
* path, and whether the method writes. Returns `null` to allow, or the Response
|
|
123
|
+
* to return. The `/public/` document read is allowed even unauthenticated; every
|
|
124
|
+
* other access needs a scope covering the path's module (or `*`).
|
|
125
|
+
*/
|
|
126
|
+
function authorize(
|
|
127
|
+
config: ResolvedConfig,
|
|
128
|
+
result: AuthResult,
|
|
129
|
+
path: string,
|
|
130
|
+
needWrite: boolean,
|
|
131
|
+
method: string,
|
|
132
|
+
): Response | null {
|
|
133
|
+
const publicRead = !needWrite && isPublicDocument(path);
|
|
134
|
+
|
|
135
|
+
if (result.kind === "anonymous") {
|
|
136
|
+
if (publicRead) return null;
|
|
137
|
+
emit(config, "warn", RemoteStorageLogEvent.AccessDenied, {
|
|
138
|
+
method,
|
|
139
|
+
status: 401,
|
|
140
|
+
});
|
|
141
|
+
return unauthorized("missing_token");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Authenticated: a public-document read always succeeds; otherwise the token
|
|
145
|
+
// must carry a scope for the path's module at the required mode. (A `rejected`
|
|
146
|
+
// result never reaches here — the caller returns before authorizing.)
|
|
147
|
+
if (result.kind === "rejected") return unauthorized(result.reason);
|
|
148
|
+
if (publicRead || authorizeScopes(result.auth.scopes, path, needWrite)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
emit(config, "warn", RemoteStorageLogEvent.AccessDenied, {
|
|
152
|
+
method,
|
|
153
|
+
status: 403,
|
|
154
|
+
});
|
|
155
|
+
return forbidden();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create the stateless remoteStorage front-door handler. CORS and auth are
|
|
160
|
+
* enforced here; storage flows through the per-account
|
|
161
|
+
* {@link RemoteStorageObject} so it remains the single consistency authority.
|
|
162
|
+
*/
|
|
163
|
+
export function createRemoteStorage(
|
|
164
|
+
config: RemoteStorageConfig,
|
|
165
|
+
): RemoteStorageHandler {
|
|
166
|
+
const resolved = resolveConfig(config);
|
|
167
|
+
|
|
168
|
+
return async (request, env, _ctx) => {
|
|
169
|
+
assertBindings(env);
|
|
170
|
+
const requestOrigin = request.headers.get("origin");
|
|
171
|
+
const method = request.method.toUpperCase();
|
|
172
|
+
|
|
173
|
+
// Preflight is answered at the edge — no auth, no store hit.
|
|
174
|
+
if (method === "OPTIONS") return preflightResponse(requestOrigin);
|
|
175
|
+
|
|
176
|
+
const respond = (response: Response): Response =>
|
|
177
|
+
withCors(response, requestOrigin);
|
|
178
|
+
|
|
179
|
+
if (!METHODS.has(method)) {
|
|
180
|
+
return respond(
|
|
181
|
+
new Response("Method Not Allowed", {
|
|
182
|
+
status: 405,
|
|
183
|
+
headers: {
|
|
184
|
+
"content-type": "text/plain; charset=utf-8",
|
|
185
|
+
allow: ALLOWED_METHODS,
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const parsed = resolved.parsePath(new URL(request.url).pathname);
|
|
192
|
+
if (!parsed) {
|
|
193
|
+
return respond(
|
|
194
|
+
new Response("Not Found", {
|
|
195
|
+
status: 404,
|
|
196
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const result = await authenticate(request, resolved);
|
|
202
|
+
if (result.kind === "rejected") {
|
|
203
|
+
emit(resolved, "warn", RemoteStorageLogEvent.AuthRejected, {
|
|
204
|
+
reason: result.reason,
|
|
205
|
+
});
|
|
206
|
+
return respond(unauthorized(result.reason));
|
|
207
|
+
}
|
|
208
|
+
if (result.kind === "authenticated") {
|
|
209
|
+
emit(resolved, "info", RemoteStorageLogEvent.AuthAccepted, {
|
|
210
|
+
...(result.auth.subject
|
|
211
|
+
? { subjectHost: hostFromUrl(result.auth.subject) }
|
|
212
|
+
: {}),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const needWrite = method === "PUT" || method === "DELETE";
|
|
217
|
+
const denied = authorize(resolved, result, parsed.path, needWrite, method);
|
|
218
|
+
if (denied) return respond(denied);
|
|
219
|
+
|
|
220
|
+
const forwarded = internalRequest(request, parsed.path, resolved);
|
|
221
|
+
const id = env.STORAGE.idFromName(parsed.account);
|
|
222
|
+
const response = await env.STORAGE.get(id).fetch(forwarded);
|
|
223
|
+
return respond(response);
|
|
224
|
+
};
|
|
225
|
+
}
|