@emdash-cms/cloudflare 0.0.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/dist/auth/index.d.mts +81 -0
- package/dist/auth/index.mjs +147 -0
- package/dist/cache/config.d.mts +52 -0
- package/dist/cache/config.mjs +55 -0
- package/dist/cache/runtime.d.mts +40 -0
- package/dist/cache/runtime.mjs +191 -0
- package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
- package/dist/db/d1.d.mts +43 -0
- package/dist/db/d1.mjs +74 -0
- package/dist/db/do.d.mts +96 -0
- package/dist/db/do.mjs +489 -0
- package/dist/db/playground-middleware.d.mts +20 -0
- package/dist/db/playground-middleware.mjs +533 -0
- package/dist/db/playground.d.mts +39 -0
- package/dist/db/playground.mjs +26 -0
- package/dist/do-class-DY2Ba2RJ.mjs +174 -0
- package/dist/do-class-x5Xh_G62.d.mts +73 -0
- package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
- package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
- package/dist/do-types-CY0G0oyh.d.mts +14 -0
- package/dist/images-4RT9Ag8_.d.mts +76 -0
- package/dist/index.d.mts +200 -0
- package/dist/index.mjs +214 -0
- package/dist/media/images-runtime.d.mts +10 -0
- package/dist/media/images-runtime.mjs +215 -0
- package/dist/media/stream-runtime.d.mts +10 -0
- package/dist/media/stream-runtime.mjs +218 -0
- package/dist/plugins/index.d.mts +32 -0
- package/dist/plugins/index.mjs +163 -0
- package/dist/sandbox/index.d.mts +255 -0
- package/dist/sandbox/index.mjs +945 -0
- package/dist/storage/r2.d.mts +31 -0
- package/dist/storage/r2.mjs +116 -0
- package/dist/stream-DdbcvKi0.d.mts +78 -0
- package/package.json +109 -0
- package/src/auth/cloudflare-access.ts +303 -0
- package/src/auth/index.ts +16 -0
- package/src/cache/config.ts +81 -0
- package/src/cache/runtime.ts +328 -0
- package/src/cloudflare.d.ts +31 -0
- package/src/db/d1-introspector.ts +120 -0
- package/src/db/d1.ts +112 -0
- package/src/db/do-class.ts +275 -0
- package/src/db/do-dialect.ts +125 -0
- package/src/db/do-playground-routes.ts +65 -0
- package/src/db/do-preview-routes.ts +48 -0
- package/src/db/do-preview-sign.ts +100 -0
- package/src/db/do-preview.ts +268 -0
- package/src/db/do-types.ts +12 -0
- package/src/db/do.ts +62 -0
- package/src/db/playground-middleware.ts +340 -0
- package/src/db/playground-toolbar.ts +341 -0
- package/src/db/playground.ts +49 -0
- package/src/db/preview-toolbar.ts +220 -0
- package/src/index.ts +285 -0
- package/src/media/images-runtime.ts +353 -0
- package/src/media/images.ts +114 -0
- package/src/media/stream-runtime.ts +392 -0
- package/src/media/stream.ts +118 -0
- package/src/plugins/index.ts +7 -0
- package/src/plugins/vectorize-search.ts +393 -0
- package/src/sandbox/bridge.ts +1008 -0
- package/src/sandbox/index.ts +13 -0
- package/src/sandbox/runner.ts +357 -0
- package/src/sandbox/types.ts +181 -0
- package/src/sandbox/wrapper.ts +238 -0
- package/src/storage/r2.ts +200 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DownloadResult, ListOptions, ListResult, SignedUploadOptions, SignedUploadUrl, Storage, UploadResult } from "emdash";
|
|
2
|
+
|
|
3
|
+
//#region src/storage/r2.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* R2 Storage implementation using native bindings
|
|
6
|
+
*/
|
|
7
|
+
declare class R2Storage implements Storage {
|
|
8
|
+
private bucket;
|
|
9
|
+
private publicUrl?;
|
|
10
|
+
constructor(bucket: R2Bucket, publicUrl?: string);
|
|
11
|
+
upload(options: {
|
|
12
|
+
key: string;
|
|
13
|
+
body: Buffer | Uint8Array | ReadableStream<Uint8Array>;
|
|
14
|
+
contentType: string;
|
|
15
|
+
}): Promise<UploadResult>;
|
|
16
|
+
download(key: string): Promise<DownloadResult>;
|
|
17
|
+
delete(key: string): Promise<void>;
|
|
18
|
+
exists(key: string): Promise<boolean>;
|
|
19
|
+
list(options?: ListOptions): Promise<ListResult>;
|
|
20
|
+
getSignedUploadUrl(_options: SignedUploadOptions): Promise<SignedUploadUrl>;
|
|
21
|
+
getPublicUrl(key: string): string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Create R2 storage adapter
|
|
25
|
+
* This is the factory function called at runtime
|
|
26
|
+
*
|
|
27
|
+
* Uses cloudflare:workers to access bindings directly.
|
|
28
|
+
*/
|
|
29
|
+
declare function createStorage(config: Record<string, unknown>): Storage;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { R2Storage, createStorage };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { env } from "cloudflare:workers";
|
|
2
|
+
import { EmDashStorageError } from "emdash";
|
|
3
|
+
|
|
4
|
+
//#region src/storage/r2.ts
|
|
5
|
+
/**
|
|
6
|
+
* Cloudflare R2 Storage Implementation - RUNTIME ENTRY
|
|
7
|
+
*
|
|
8
|
+
* Uses R2 bindings directly when running on Cloudflare Workers.
|
|
9
|
+
* This avoids the AWS SDK overhead and works with the native R2 API.
|
|
10
|
+
*
|
|
11
|
+
* This module imports directly from cloudflare:workers to access R2 bindings.
|
|
12
|
+
* Do NOT import this at config time - use { r2 } from "@emdash-cms/cloudflare" instead.
|
|
13
|
+
*
|
|
14
|
+
* For Astro 6 / Cloudflare adapter v13+:
|
|
15
|
+
* - Bindings are accessed via `import { env } from 'cloudflare:workers'`
|
|
16
|
+
*/
|
|
17
|
+
/** Regex to remove trailing slashes from URLs */
|
|
18
|
+
const TRAILING_SLASH_REGEX = /\/$/;
|
|
19
|
+
/**
|
|
20
|
+
* R2 Storage implementation using native bindings
|
|
21
|
+
*/
|
|
22
|
+
var R2Storage = class {
|
|
23
|
+
bucket;
|
|
24
|
+
publicUrl;
|
|
25
|
+
constructor(bucket, publicUrl) {
|
|
26
|
+
this.bucket = bucket;
|
|
27
|
+
this.publicUrl = publicUrl;
|
|
28
|
+
}
|
|
29
|
+
async upload(options) {
|
|
30
|
+
try {
|
|
31
|
+
const result = await this.bucket.put(options.key, options.body, { httpMetadata: { contentType: options.contentType } });
|
|
32
|
+
if (!result) throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED");
|
|
33
|
+
return {
|
|
34
|
+
key: options.key,
|
|
35
|
+
url: this.getPublicUrl(options.key),
|
|
36
|
+
size: result.size
|
|
37
|
+
};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof EmDashStorageError) throw error;
|
|
40
|
+
throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async download(key) {
|
|
44
|
+
try {
|
|
45
|
+
const object = await this.bucket.get(key);
|
|
46
|
+
if (!object) throw new EmDashStorageError(`File not found: ${key}`, "NOT_FOUND");
|
|
47
|
+
if (!("body" in object) || !object.body) throw new EmDashStorageError(`File not found: ${key}`, "NOT_FOUND");
|
|
48
|
+
return {
|
|
49
|
+
body: object.body,
|
|
50
|
+
contentType: object.httpMetadata?.contentType || "application/octet-stream",
|
|
51
|
+
size: object.size
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof EmDashStorageError) throw error;
|
|
55
|
+
throw new EmDashStorageError(`Failed to download file: ${key}`, "DOWNLOAD_FAILED", error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async delete(key) {
|
|
59
|
+
try {
|
|
60
|
+
await this.bucket.delete(key);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new EmDashStorageError(`Failed to delete file: ${key}`, "DELETE_FAILED", error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async exists(key) {
|
|
66
|
+
try {
|
|
67
|
+
return await this.bucket.head(key) !== null;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new EmDashStorageError(`Failed to check file existence: ${key}`, "HEAD_FAILED", error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async list(options = {}) {
|
|
73
|
+
try {
|
|
74
|
+
const response = await this.bucket.list({
|
|
75
|
+
prefix: options.prefix,
|
|
76
|
+
limit: options.limit,
|
|
77
|
+
cursor: options.cursor
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
files: response.objects.map((item) => ({
|
|
81
|
+
key: item.key,
|
|
82
|
+
size: item.size,
|
|
83
|
+
lastModified: item.uploaded,
|
|
84
|
+
etag: item.etag
|
|
85
|
+
})),
|
|
86
|
+
nextCursor: response.truncated ? response.cursor : void 0
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new EmDashStorageError("Failed to list files", "LIST_FAILED", error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async getSignedUploadUrl(_options) {
|
|
93
|
+
throw new EmDashStorageError("R2 bindings do not support pre-signed upload URLs. Use the S3 API with R2 credentials for signed URL support, or upload through the Worker.", "NOT_SUPPORTED");
|
|
94
|
+
}
|
|
95
|
+
getPublicUrl(key) {
|
|
96
|
+
if (this.publicUrl) return `${this.publicUrl.replace(TRAILING_SLASH_REGEX, "")}/${key}`;
|
|
97
|
+
return `/_emdash/api/media/file/${key}`;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Create R2 storage adapter
|
|
102
|
+
* This is the factory function called at runtime
|
|
103
|
+
*
|
|
104
|
+
* Uses cloudflare:workers to access bindings directly.
|
|
105
|
+
*/
|
|
106
|
+
function createStorage(config) {
|
|
107
|
+
const binding = typeof config.binding === "string" ? config.binding : "";
|
|
108
|
+
const publicUrl = typeof config.publicUrl === "string" ? config.publicUrl : void 0;
|
|
109
|
+
if (!binding) throw new EmDashStorageError(`R2 binding name is required in storage config.`, "BINDING_NOT_FOUND");
|
|
110
|
+
const bucket = env[binding];
|
|
111
|
+
if (!bucket) throw new EmDashStorageError(`R2 binding "${binding}" not found. Make sure the binding is defined in wrangler.jsonc and you're running on Cloudflare Workers.\n\nExample wrangler.jsonc:\n{\n "r2_buckets": [{\n "binding": "${binding}",\n "bucket_name": "my-bucket"\n }]\n}`, "BINDING_NOT_FOUND");
|
|
112
|
+
return new R2Storage(bucket, publicUrl);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
//#endregion
|
|
116
|
+
export { R2Storage, createStorage };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { MediaProviderDescriptor } from "emdash/media";
|
|
2
|
+
|
|
3
|
+
//#region src/media/stream.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Cloudflare Stream configuration
|
|
6
|
+
*/
|
|
7
|
+
interface CloudflareStreamConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Cloudflare Account ID
|
|
10
|
+
* If not provided, reads from accountIdEnvVar at runtime
|
|
11
|
+
*/
|
|
12
|
+
accountId?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Environment variable name containing the Account ID
|
|
15
|
+
* @default "CF_ACCOUNT_ID"
|
|
16
|
+
*/
|
|
17
|
+
accountIdEnvVar?: string;
|
|
18
|
+
/**
|
|
19
|
+
* API Token with Stream permissions
|
|
20
|
+
* If not provided, reads from apiTokenEnvVar at runtime
|
|
21
|
+
* Should have "Stream: Read" and "Stream: Edit" permissions
|
|
22
|
+
*/
|
|
23
|
+
apiToken?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Environment variable name containing the API token
|
|
26
|
+
* @default "CF_STREAM_TOKEN"
|
|
27
|
+
*/
|
|
28
|
+
apiTokenEnvVar?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Customer subdomain for Stream delivery (optional)
|
|
31
|
+
* If not provided, uses customer-{hash}.cloudflarestream.com format
|
|
32
|
+
*/
|
|
33
|
+
customerSubdomain?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Default player controls setting
|
|
36
|
+
* @default true
|
|
37
|
+
*/
|
|
38
|
+
controls?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Autoplay videos (muted by default to comply with browser policies)
|
|
41
|
+
* @default false
|
|
42
|
+
*/
|
|
43
|
+
autoplay?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Loop videos
|
|
46
|
+
* @default false
|
|
47
|
+
*/
|
|
48
|
+
loop?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Mute videos
|
|
51
|
+
* @default false (true if autoplay is enabled)
|
|
52
|
+
*/
|
|
53
|
+
muted?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Cloudflare Stream media provider
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* import { cloudflareStream } from "@emdash-cms/cloudflare";
|
|
61
|
+
*
|
|
62
|
+
* emdash({
|
|
63
|
+
* mediaProviders: [
|
|
64
|
+
* // Uses CF_ACCOUNT_ID and CF_STREAM_TOKEN env vars by default
|
|
65
|
+
* cloudflareStream({}),
|
|
66
|
+
*
|
|
67
|
+
* // Or with custom env var names
|
|
68
|
+
* cloudflareStream({
|
|
69
|
+
* accountIdEnvVar: "MY_CF_ACCOUNT",
|
|
70
|
+
* apiTokenEnvVar: "MY_CF_STREAM_KEY",
|
|
71
|
+
* }),
|
|
72
|
+
* ],
|
|
73
|
+
* })
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
declare function cloudflareStream(config: CloudflareStreamConfig): MediaProviderDescriptor<CloudflareStreamConfig>;
|
|
77
|
+
//#endregion
|
|
78
|
+
export { cloudflareStream as n, CloudflareStreamConfig as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@emdash-cms/cloudflare",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Cloudflare adapters for EmDash - D1, R2, Access, and Worker Loader sandbox",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.mjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"default": "./dist/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"./db/d1": {
|
|
17
|
+
"types": "./dist/db/d1.d.mts",
|
|
18
|
+
"default": "./dist/db/d1.mjs"
|
|
19
|
+
},
|
|
20
|
+
"./db/do": {
|
|
21
|
+
"types": "./dist/db/do.d.mts",
|
|
22
|
+
"default": "./dist/db/do.mjs"
|
|
23
|
+
},
|
|
24
|
+
"./db/playground": {
|
|
25
|
+
"types": "./dist/db/playground.d.mts",
|
|
26
|
+
"default": "./dist/db/playground.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./db/playground-middleware": {
|
|
29
|
+
"types": "./dist/db/playground-middleware.d.mts",
|
|
30
|
+
"default": "./dist/db/playground-middleware.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./storage/r2": {
|
|
33
|
+
"types": "./dist/storage/r2.d.mts",
|
|
34
|
+
"default": "./dist/storage/r2.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./auth": {
|
|
37
|
+
"types": "./dist/auth/index.d.mts",
|
|
38
|
+
"default": "./dist/auth/index.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./sandbox": {
|
|
41
|
+
"types": "./dist/sandbox/index.d.mts",
|
|
42
|
+
"default": "./dist/sandbox/index.mjs"
|
|
43
|
+
},
|
|
44
|
+
"./plugins": {
|
|
45
|
+
"types": "./dist/plugins/index.d.mts",
|
|
46
|
+
"default": "./dist/plugins/index.mjs"
|
|
47
|
+
},
|
|
48
|
+
"./media/images-runtime": {
|
|
49
|
+
"types": "./dist/media/images-runtime.d.mts",
|
|
50
|
+
"default": "./dist/media/images-runtime.mjs"
|
|
51
|
+
},
|
|
52
|
+
"./media/stream-runtime": {
|
|
53
|
+
"types": "./dist/media/stream-runtime.d.mts",
|
|
54
|
+
"default": "./dist/media/stream-runtime.mjs"
|
|
55
|
+
},
|
|
56
|
+
"./cache": {
|
|
57
|
+
"types": "./dist/cache/runtime.d.mts",
|
|
58
|
+
"default": "./dist/cache/runtime.mjs"
|
|
59
|
+
},
|
|
60
|
+
"./cache/config": {
|
|
61
|
+
"types": "./dist/cache/config.d.mts",
|
|
62
|
+
"default": "./dist/cache/config.mjs"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"jose": "^6.1.3",
|
|
67
|
+
"kysely-d1": "^0.4.0",
|
|
68
|
+
"ulidx": "^2.4.1",
|
|
69
|
+
"emdash": "0.0.1"
|
|
70
|
+
},
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"@cloudflare/workers-types": ">=4.0.0",
|
|
73
|
+
"astro": ">=6.0.0-beta.0",
|
|
74
|
+
"kysely": ">=0.27.0"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@arethetypeswrong/cli": "^0.18.2",
|
|
78
|
+
"@cloudflare/workers-types": "^4.20260305.1",
|
|
79
|
+
"publint": "0.3.17",
|
|
80
|
+
"tsdown": "0.20.3",
|
|
81
|
+
"typescript": "^5.9.3",
|
|
82
|
+
"vitest": "^4.0.18"
|
|
83
|
+
},
|
|
84
|
+
"repository": {
|
|
85
|
+
"type": "git",
|
|
86
|
+
"url": "git+https://github.com/cloudflare/emdash.git",
|
|
87
|
+
"directory": "packages/cloudflare"
|
|
88
|
+
},
|
|
89
|
+
"homepage": "https://github.com/cloudflare/emdash",
|
|
90
|
+
"keywords": [
|
|
91
|
+
"emdash",
|
|
92
|
+
"cloudflare",
|
|
93
|
+
"d1",
|
|
94
|
+
"r2",
|
|
95
|
+
"access",
|
|
96
|
+
"worker-loader",
|
|
97
|
+
"sandbox",
|
|
98
|
+
"plugins"
|
|
99
|
+
],
|
|
100
|
+
"author": "Matt Kane",
|
|
101
|
+
"license": "MIT",
|
|
102
|
+
"scripts": {
|
|
103
|
+
"build": "tsdown",
|
|
104
|
+
"dev": "tsdown --watch",
|
|
105
|
+
"test": "vitest run",
|
|
106
|
+
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution",
|
|
107
|
+
"typecheck": "tsgo --noEmit"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Access Authentication - RUNTIME MODULE
|
|
3
|
+
*
|
|
4
|
+
* When EmDash is deployed behind Cloudflare Access, this module handles
|
|
5
|
+
* JWT validation and user provisioning from Access identity.
|
|
6
|
+
*
|
|
7
|
+
* Uses jose for JWT verification - works in all runtimes.
|
|
8
|
+
*
|
|
9
|
+
* This is loaded at runtime via the auth provider system.
|
|
10
|
+
* Do not import at config time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
|
14
|
+
import type { AuthResult } from "emdash";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Configuration for Cloudflare Access authentication
|
|
18
|
+
*
|
|
19
|
+
* Note: This interface is duplicated in ../index.ts for config-time usage.
|
|
20
|
+
* Keep them in sync.
|
|
21
|
+
*/
|
|
22
|
+
export interface AccessConfig {
|
|
23
|
+
/**
|
|
24
|
+
* Your Cloudflare Access team domain
|
|
25
|
+
* @example "myteam.cloudflareaccess.com"
|
|
26
|
+
*/
|
|
27
|
+
teamDomain: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Application Audience (AUD) tag from Access application settings.
|
|
31
|
+
* For Cloudflare Workers, use `audienceEnvVar` instead to read at runtime.
|
|
32
|
+
*/
|
|
33
|
+
audience?: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Environment variable name containing the audience tag.
|
|
37
|
+
* Read at runtime from environment.
|
|
38
|
+
* @default "CF_ACCESS_AUDIENCE"
|
|
39
|
+
*/
|
|
40
|
+
audienceEnvVar?: string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Role level for users not matching any group in roleMapping
|
|
44
|
+
* @default 30 (Editor)
|
|
45
|
+
*/
|
|
46
|
+
defaultRole?: number;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map IdP group names to EmDash role levels
|
|
50
|
+
*/
|
|
51
|
+
roleMapping?: Record<string, number>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cloudflare Access JWT payload extends standard JWT with email claim
|
|
56
|
+
*/
|
|
57
|
+
export interface AccessJwtPayload extends JWTPayload {
|
|
58
|
+
/** User's email address (Access-specific claim) */
|
|
59
|
+
email: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Group from IdP (returned by get-identity endpoint)
|
|
64
|
+
*/
|
|
65
|
+
export interface AccessGroup {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
email?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Full identity from Access get-identity endpoint
|
|
73
|
+
*/
|
|
74
|
+
export interface AccessIdentity {
|
|
75
|
+
/** Unique identity ID */
|
|
76
|
+
id: string;
|
|
77
|
+
/** User's display name (may be undefined if IdP doesn't provide it) */
|
|
78
|
+
name?: string;
|
|
79
|
+
/** User's email address */
|
|
80
|
+
email: string;
|
|
81
|
+
/** Groups from IdP */
|
|
82
|
+
groups: AccessGroup[];
|
|
83
|
+
/** Identity provider info */
|
|
84
|
+
idp: {
|
|
85
|
+
id: string;
|
|
86
|
+
type: string;
|
|
87
|
+
};
|
|
88
|
+
/** Custom OIDC claims from IdP */
|
|
89
|
+
oidc_fields?: Record<string, unknown>;
|
|
90
|
+
/** SAML attributes from IdP */
|
|
91
|
+
saml_attributes?: Record<string, unknown>;
|
|
92
|
+
/** User's country (from geo) */
|
|
93
|
+
geo?: {
|
|
94
|
+
country: string;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Cache for JWKS (jose handles key rotation automatically)
|
|
99
|
+
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
|
100
|
+
|
|
101
|
+
/** Regex to extract CF_Authorization cookie value */
|
|
102
|
+
const CF_AUTHORIZATION_COOKIE_REGEX = /CF_Authorization=([^;]+)/;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get or create a JWKS client for the given team domain
|
|
106
|
+
*/
|
|
107
|
+
function getJwks(teamDomain: string): ReturnType<typeof createRemoteJWKSet> {
|
|
108
|
+
let jwks = jwksCache.get(teamDomain);
|
|
109
|
+
if (!jwks) {
|
|
110
|
+
const jwksUrl = new URL(`https://${teamDomain}/cdn-cgi/access/certs`);
|
|
111
|
+
jwks = createRemoteJWKSet(jwksUrl);
|
|
112
|
+
jwksCache.set(teamDomain, jwks);
|
|
113
|
+
}
|
|
114
|
+
return jwks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Default environment variable name for Access audience */
|
|
118
|
+
const DEFAULT_AUDIENCE_ENV_VAR = "CF_ACCESS_AUDIENCE";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the audience value from config.
|
|
122
|
+
* Supports direct value or reading from environment variable.
|
|
123
|
+
*/
|
|
124
|
+
function resolveAudience(config: AccessConfig): string {
|
|
125
|
+
// Direct value takes precedence
|
|
126
|
+
if (config.audience) {
|
|
127
|
+
return config.audience;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Read from environment
|
|
131
|
+
const envVarName = config.audienceEnvVar ?? DEFAULT_AUDIENCE_ENV_VAR;
|
|
132
|
+
const value = process.env[envVarName];
|
|
133
|
+
|
|
134
|
+
if (typeof value === "string" && value) {
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Environment variable "${envVarName}" not found or empty. ` +
|
|
140
|
+
`Set it via wrangler secret, .dev.vars, or environment.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate a Cloudflare Access JWT using jose
|
|
146
|
+
*
|
|
147
|
+
* @param jwt The JWT string from header or cookie
|
|
148
|
+
* @param config Access configuration
|
|
149
|
+
* @returns Decoded and validated JWT payload
|
|
150
|
+
* @throws Error if validation fails
|
|
151
|
+
*/
|
|
152
|
+
export async function validateAccessJwt(
|
|
153
|
+
jwt: string,
|
|
154
|
+
config: AccessConfig,
|
|
155
|
+
): Promise<AccessJwtPayload> {
|
|
156
|
+
const audience = resolveAudience(config);
|
|
157
|
+
const issuer = `https://${config.teamDomain}`;
|
|
158
|
+
const jwks = getJwks(config.teamDomain);
|
|
159
|
+
|
|
160
|
+
const { payload } = await jwtVerify<AccessJwtPayload>(jwt, jwks, {
|
|
161
|
+
issuer,
|
|
162
|
+
audience,
|
|
163
|
+
clockTolerance: 60, // 60 seconds clock skew tolerance
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return payload;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Extract Access JWT from request
|
|
171
|
+
*
|
|
172
|
+
* Checks header first (more reliable), then falls back to cookie.
|
|
173
|
+
*
|
|
174
|
+
* @param request The incoming request
|
|
175
|
+
* @returns JWT string or null if not present
|
|
176
|
+
*/
|
|
177
|
+
export function extractAccessJwt(request: Request): string | null {
|
|
178
|
+
// Try header first (preferred - set by Access on all requests)
|
|
179
|
+
const headerJwt = request.headers.get("Cf-Access-Jwt-Assertion");
|
|
180
|
+
if (headerJwt) {
|
|
181
|
+
return headerJwt;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Fall back to cookie (set in browser)
|
|
185
|
+
const cookies = request.headers.get("Cookie") || "";
|
|
186
|
+
const match = cookies.match(CF_AUTHORIZATION_COOKIE_REGEX);
|
|
187
|
+
return match?.[1] || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Fetch full identity from Access (includes groups)
|
|
192
|
+
*
|
|
193
|
+
* The JWT itself only contains basic claims. To get groups and other
|
|
194
|
+
* IdP attributes, we need to call the get-identity endpoint.
|
|
195
|
+
*
|
|
196
|
+
* @param jwt The JWT string
|
|
197
|
+
* @param teamDomain The Access team domain
|
|
198
|
+
* @returns Full identity including groups
|
|
199
|
+
*/
|
|
200
|
+
export async function getAccessIdentity(jwt: string, teamDomain: string): Promise<AccessIdentity> {
|
|
201
|
+
const response = await fetch(`https://${teamDomain}/cdn-cgi/access/get-identity`, {
|
|
202
|
+
headers: {
|
|
203
|
+
Cookie: `CF_Authorization=${jwt}`,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(`Failed to fetch identity: ${response.status}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return response.json();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve role from IdP groups using roleMapping config
|
|
216
|
+
*
|
|
217
|
+
* @param groups User's groups from IdP
|
|
218
|
+
* @param config Access configuration
|
|
219
|
+
* @returns Role level (e.g., 50 for Admin, 30 for Editor)
|
|
220
|
+
*/
|
|
221
|
+
export function resolveRoleFromGroups(groups: AccessGroup[], config: AccessConfig): number {
|
|
222
|
+
const defaultRole = config.defaultRole ?? 30; // Editor
|
|
223
|
+
|
|
224
|
+
if (!config.roleMapping) {
|
|
225
|
+
return defaultRole;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check each group against mapping (first match wins)
|
|
229
|
+
for (const group of groups) {
|
|
230
|
+
const role = config.roleMapping[group.name];
|
|
231
|
+
if (role !== undefined) {
|
|
232
|
+
return role;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return defaultRole;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Authenticate a request using Cloudflare Access
|
|
241
|
+
*
|
|
242
|
+
* This is the main entry point for Access authentication.
|
|
243
|
+
* It validates the JWT, fetches the full identity, and resolves the role.
|
|
244
|
+
*
|
|
245
|
+
* This function implements the AuthProviderModule.authenticate interface.
|
|
246
|
+
*
|
|
247
|
+
* @param request The incoming request
|
|
248
|
+
* @param config Access configuration (passed from AuthDescriptor)
|
|
249
|
+
* @returns Authentication result with user info and role
|
|
250
|
+
* @throws Error if authentication fails
|
|
251
|
+
*/
|
|
252
|
+
function isAccessConfig(value: unknown): value is AccessConfig {
|
|
253
|
+
return (
|
|
254
|
+
value != null &&
|
|
255
|
+
typeof value === "object" &&
|
|
256
|
+
"teamDomain" in value &&
|
|
257
|
+
typeof value.teamDomain === "string"
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function authenticate(request: Request, config: unknown): Promise<AuthResult> {
|
|
262
|
+
if (!isAccessConfig(config)) {
|
|
263
|
+
throw new Error("Invalid Cloudflare Access config: teamDomain is required");
|
|
264
|
+
}
|
|
265
|
+
const accessConfig = config;
|
|
266
|
+
|
|
267
|
+
// Extract JWT
|
|
268
|
+
const jwt = extractAccessJwt(request);
|
|
269
|
+
if (!jwt) {
|
|
270
|
+
throw new Error("No Access JWT present");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Validate JWT
|
|
274
|
+
const payload = await validateAccessJwt(jwt, accessConfig);
|
|
275
|
+
|
|
276
|
+
// Fetch full identity (includes groups)
|
|
277
|
+
const identity = await getAccessIdentity(jwt, accessConfig.teamDomain);
|
|
278
|
+
|
|
279
|
+
// Resolve role from groups
|
|
280
|
+
const role = resolveRoleFromGroups(identity.groups, accessConfig);
|
|
281
|
+
|
|
282
|
+
// Log identity for debugging
|
|
283
|
+
console.log(
|
|
284
|
+
"[cf-access] Identity from Access:",
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
email: identity.email,
|
|
287
|
+
name: identity.name,
|
|
288
|
+
groups: identity.groups?.map((g) => g.name),
|
|
289
|
+
idp: identity.idp,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
email: identity.email,
|
|
295
|
+
name: identity.name ?? identity.email.split("@")[0] ?? "Unknown",
|
|
296
|
+
role,
|
|
297
|
+
subject: payload.sub,
|
|
298
|
+
metadata: {
|
|
299
|
+
groups: identity.groups,
|
|
300
|
+
idp: identity.idp,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Access Auth - RUNTIME ENTRY
|
|
3
|
+
*
|
|
4
|
+
* This module is loaded at runtime when authenticating requests.
|
|
5
|
+
* It exports the `authenticate` function required by the auth provider interface.
|
|
6
|
+
*
|
|
7
|
+
* For config-time usage, import { access } from "@emdash-cms/cloudflare" instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { authenticate } from "./cloudflare-access.js";
|
|
11
|
+
export type {
|
|
12
|
+
AccessConfig,
|
|
13
|
+
AccessJwtPayload,
|
|
14
|
+
AccessGroup,
|
|
15
|
+
AccessIdentity,
|
|
16
|
+
} from "./cloudflare-access.js";
|