@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.
Files changed (67) hide show
  1. package/dist/auth/index.d.mts +81 -0
  2. package/dist/auth/index.mjs +147 -0
  3. package/dist/cache/config.d.mts +52 -0
  4. package/dist/cache/config.mjs +55 -0
  5. package/dist/cache/runtime.d.mts +40 -0
  6. package/dist/cache/runtime.mjs +191 -0
  7. package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
  8. package/dist/db/d1.d.mts +43 -0
  9. package/dist/db/d1.mjs +74 -0
  10. package/dist/db/do.d.mts +96 -0
  11. package/dist/db/do.mjs +489 -0
  12. package/dist/db/playground-middleware.d.mts +20 -0
  13. package/dist/db/playground-middleware.mjs +533 -0
  14. package/dist/db/playground.d.mts +39 -0
  15. package/dist/db/playground.mjs +26 -0
  16. package/dist/do-class-DY2Ba2RJ.mjs +174 -0
  17. package/dist/do-class-x5Xh_G62.d.mts +73 -0
  18. package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
  19. package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
  20. package/dist/do-types-CY0G0oyh.d.mts +14 -0
  21. package/dist/images-4RT9Ag8_.d.mts +76 -0
  22. package/dist/index.d.mts +200 -0
  23. package/dist/index.mjs +214 -0
  24. package/dist/media/images-runtime.d.mts +10 -0
  25. package/dist/media/images-runtime.mjs +215 -0
  26. package/dist/media/stream-runtime.d.mts +10 -0
  27. package/dist/media/stream-runtime.mjs +218 -0
  28. package/dist/plugins/index.d.mts +32 -0
  29. package/dist/plugins/index.mjs +163 -0
  30. package/dist/sandbox/index.d.mts +255 -0
  31. package/dist/sandbox/index.mjs +945 -0
  32. package/dist/storage/r2.d.mts +31 -0
  33. package/dist/storage/r2.mjs +116 -0
  34. package/dist/stream-DdbcvKi0.d.mts +78 -0
  35. package/package.json +109 -0
  36. package/src/auth/cloudflare-access.ts +303 -0
  37. package/src/auth/index.ts +16 -0
  38. package/src/cache/config.ts +81 -0
  39. package/src/cache/runtime.ts +328 -0
  40. package/src/cloudflare.d.ts +31 -0
  41. package/src/db/d1-introspector.ts +120 -0
  42. package/src/db/d1.ts +112 -0
  43. package/src/db/do-class.ts +275 -0
  44. package/src/db/do-dialect.ts +125 -0
  45. package/src/db/do-playground-routes.ts +65 -0
  46. package/src/db/do-preview-routes.ts +48 -0
  47. package/src/db/do-preview-sign.ts +100 -0
  48. package/src/db/do-preview.ts +268 -0
  49. package/src/db/do-types.ts +12 -0
  50. package/src/db/do.ts +62 -0
  51. package/src/db/playground-middleware.ts +340 -0
  52. package/src/db/playground-toolbar.ts +341 -0
  53. package/src/db/playground.ts +49 -0
  54. package/src/db/preview-toolbar.ts +220 -0
  55. package/src/index.ts +285 -0
  56. package/src/media/images-runtime.ts +353 -0
  57. package/src/media/images.ts +114 -0
  58. package/src/media/stream-runtime.ts +392 -0
  59. package/src/media/stream.ts +118 -0
  60. package/src/plugins/index.ts +7 -0
  61. package/src/plugins/vectorize-search.ts +393 -0
  62. package/src/sandbox/bridge.ts +1008 -0
  63. package/src/sandbox/index.ts +13 -0
  64. package/src/sandbox/runner.ts +357 -0
  65. package/src/sandbox/types.ts +181 -0
  66. package/src/sandbox/wrapper.ts +238 -0
  67. 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";