alepha 0.19.4 → 0.19.5

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 (104) hide show
  1. package/dist/api/audits/index.d.ts +8 -8
  2. package/dist/api/issues/index.d.ts +810 -0
  3. package/dist/api/issues/index.d.ts.map +1 -0
  4. package/dist/api/issues/index.js +447 -0
  5. package/dist/api/issues/index.js.map +1 -0
  6. package/dist/api/keys/index.d.ts +5 -5
  7. package/dist/api/users/index.d.ts +6 -0
  8. package/dist/api/users/index.d.ts.map +1 -1
  9. package/dist/api/users/index.js +10 -3
  10. package/dist/api/users/index.js.map +1 -1
  11. package/dist/api/workflows/index.d.ts +3 -3
  12. package/dist/captcha/index.d.ts +142 -0
  13. package/dist/captcha/index.d.ts.map +1 -0
  14. package/dist/captcha/index.js +177 -0
  15. package/dist/captcha/index.js.map +1 -0
  16. package/dist/cli/core/index.d.ts +82 -2
  17. package/dist/cli/core/index.d.ts.map +1 -1
  18. package/dist/cli/core/index.js +90 -6
  19. package/dist/cli/core/index.js.map +1 -1
  20. package/dist/cli/platform/index.d.ts +84 -10
  21. package/dist/cli/platform/index.d.ts.map +1 -1
  22. package/dist/cli/platform/index.js +92 -4
  23. package/dist/cli/platform/index.js.map +1 -1
  24. package/dist/cli/vendor/index.d.ts +30 -3
  25. package/dist/cli/vendor/index.d.ts.map +1 -1
  26. package/dist/cli/vendor/index.js +98 -21
  27. package/dist/cli/vendor/index.js.map +1 -1
  28. package/dist/command/index.d.ts.map +1 -1
  29. package/dist/command/index.js +2 -3
  30. package/dist/command/index.js.map +1 -1
  31. package/dist/orm/core/index.bun.js +6 -6
  32. package/dist/orm/core/index.bun.js.map +1 -1
  33. package/dist/orm/core/index.d.ts.map +1 -1
  34. package/dist/orm/core/index.js +6 -6
  35. package/dist/orm/core/index.js.map +1 -1
  36. package/dist/react/i18n/index.d.ts +1 -0
  37. package/dist/react/i18n/index.d.ts.map +1 -1
  38. package/dist/react/i18n/index.js +8 -4
  39. package/dist/react/i18n/index.js.map +1 -1
  40. package/dist/security/index.d.ts.map +1 -1
  41. package/dist/security/index.js.map +1 -1
  42. package/dist/server/auth/index.d.ts +145 -2
  43. package/dist/server/auth/index.d.ts.map +1 -1
  44. package/dist/server/auth/index.js +364 -63
  45. package/dist/server/auth/index.js.map +1 -1
  46. package/dist/server/cookies/index.d.ts.map +1 -1
  47. package/dist/server/cookies/index.js.map +1 -1
  48. package/dist/websocket/index.d.ts.map +1 -1
  49. package/dist/websocket/index.js.map +1 -1
  50. package/package.json +11 -1
  51. package/src/api/issues/__tests__/IssueService.spec.ts +263 -0
  52. package/src/api/issues/controllers/AdminIssueController.ts +149 -0
  53. package/src/api/issues/controllers/IssueController.ts +44 -0
  54. package/src/api/issues/entities/issues.ts +49 -0
  55. package/src/api/issues/index.ts +53 -0
  56. package/src/api/issues/schemas/createIssueSchema.ts +13 -0
  57. package/src/api/issues/schemas/issueConfigAtom.ts +13 -0
  58. package/src/api/issues/schemas/issueQuerySchema.ts +18 -0
  59. package/src/api/issues/schemas/issueResourceSchema.ts +6 -0
  60. package/src/api/issues/schemas/myIssueQuerySchema.ts +10 -0
  61. package/src/api/issues/schemas/updateIssueSchema.ts +13 -0
  62. package/src/api/issues/services/IssueService.ts +264 -0
  63. package/src/api/users/primitives/$realm.ts +24 -0
  64. package/src/api/users/services/CredentialService.ts +6 -3
  65. package/src/api/users/services/RegistrationService.ts +15 -5
  66. package/src/api/users/services/SessionService.ts +2 -0
  67. package/src/captcha/__tests__/MemoryCaptchaProvider.spec.ts +74 -0
  68. package/src/captcha/index.ts +33 -0
  69. package/src/captcha/providers/CaptchaProvider.ts +17 -0
  70. package/src/captcha/providers/MemoryCaptchaProvider.ts +65 -0
  71. package/src/captcha/providers/TurnstileCaptchaProvider.ts +125 -0
  72. package/src/cli/core/atoms/buildOptions.ts +57 -0
  73. package/src/cli/core/commands/build.ts +2 -0
  74. package/src/cli/core/providers/ViteDevServerProvider.ts +1 -1
  75. package/src/cli/core/services/ViteUtils.ts +5 -2
  76. package/src/cli/core/tasks/BuildClientTask.ts +3 -1
  77. package/src/cli/core/tasks/BuildCloudflareTask.ts +4 -0
  78. package/src/cli/core/tasks/BuildPwaTask.ts +81 -0
  79. package/src/cli/platform/adapters/CloudflareAdapter.ts +24 -0
  80. package/src/cli/platform/atoms/platformOptions.ts +19 -3
  81. package/src/cli/platform/hooks/PlatformHook.ts +51 -0
  82. package/src/cli/platform/index.ts +1 -0
  83. package/src/cli/platform/services/CloudflareApi.ts +22 -1
  84. package/src/cli/platform/services/PlatformOrchestrator.ts +67 -2
  85. package/src/cli/vendor/__tests__/VendorService.spec.ts +40 -1
  86. package/src/cli/vendor/commands/VendorCommand.ts +41 -38
  87. package/src/cli/vendor/services/VendorService.ts +108 -4
  88. package/src/command/__tests__/CliProvider.spec.ts +45 -0
  89. package/src/command/providers/CliProvider.ts +3 -4
  90. package/src/orm/core/services/Repository.ts +20 -6
  91. package/src/react/i18n/__tests__/I18nProvider.spec.ts +83 -0
  92. package/src/react/i18n/providers/I18nProvider.ts +12 -10
  93. package/src/security/primitives/$issuer.ts +3 -1
  94. package/src/server/auth/index.ts +7 -0
  95. package/src/server/auth/primitives/$auth.ts +37 -3
  96. package/src/server/auth/primitives/$authApple.ts +114 -4
  97. package/src/server/auth/primitives/$authFacebook.ts +98 -0
  98. package/src/server/auth/primitives/$authFranceConnect.ts +105 -0
  99. package/src/server/auth/primitives/$authGithub.ts +22 -16
  100. package/src/server/auth/primitives/$authMicrosoft.ts +88 -0
  101. package/src/server/auth/providers/ServerAuthProvider.ts +197 -72
  102. package/src/server/cookies/providers/ServerCookiesProvider.ts +3 -0
  103. package/src/server/core/__tests__/ServerRouterProvider-errorHandler.spec.ts +1 -1
  104. package/src/websocket/providers/NodeWebSocketServerProvider.ts +3 -1
@@ -0,0 +1,125 @@
1
+ import { $context, AlephaError, t } from "alepha";
2
+ import { $logger } from "alepha/logger";
3
+ import type { CaptchaProvider } from "./CaptchaProvider.ts";
4
+
5
+ /**
6
+ * Cloudflare Turnstile captcha verification provider.
7
+ *
8
+ * Validates captcha tokens against the Cloudflare Turnstile siteverify API.
9
+ * Free, privacy-friendly, and supports invisible mode.
10
+ *
11
+ * ## Setup
12
+ *
13
+ * 1. Create a Turnstile widget at https://dash.cloudflare.com/?to=/:account/turnstile
14
+ * 2. Copy the **Site Key** (public, for the client) and **Secret Key** (private, for the server)
15
+ * 3. Set `TURNSTILE_SECRET_KEY` in your environment
16
+ *
17
+ * ## Client-side integration
18
+ *
19
+ * Add the Turnstile script and widget to your form:
20
+ *
21
+ * ```html
22
+ * <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
23
+ * <form>
24
+ * <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
25
+ * <button type="submit">Submit</button>
26
+ * </form>
27
+ * ```
28
+ *
29
+ * The widget injects a hidden `cf-turnstile-response` input into the form.
30
+ * Send this value as the `captchaToken` in your registration request.
31
+ *
32
+ * For explicit rendering (React, SPA):
33
+ *
34
+ * ```ts
35
+ * turnstile.render("#container", {
36
+ * sitekey: "YOUR_SITE_KEY",
37
+ * callback: (token) => setCaptchaToken(token),
38
+ * });
39
+ * ```
40
+ *
41
+ * ## Server-side usage
42
+ *
43
+ * Register the provider in your app:
44
+ *
45
+ * ```ts
46
+ * import { CaptchaProvider } from "alepha/captcha";
47
+ * import { TurnstileCaptchaProvider } from "alepha/captcha";
48
+ *
49
+ * alepha.with({ provide: CaptchaProvider, use: TurnstileCaptchaProvider });
50
+ * ```
51
+ *
52
+ * ## Test keys (for development)
53
+ *
54
+ * - Always passes: site `1x00000000000000000000AA`, secret `1x0000000000000000000000000000000AA`
55
+ * - Always blocks: site `2x00000000000000000000AB`, secret `2x0000000000000000000000000000000AB`
56
+ * - Forces interactive: site `3x00000000000000000000FF`
57
+ *
58
+ * ## Environment Variables
59
+ *
60
+ * - `TURNSTILE_SECRET_KEY`: The secret key from the Cloudflare Turnstile dashboard.
61
+ *
62
+ * @see https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
63
+ */
64
+ export class TurnstileCaptchaProvider implements CaptchaProvider {
65
+ protected readonly log = $logger();
66
+ protected readonly secretKey: string;
67
+
68
+ constructor() {
69
+ const { alepha } = $context();
70
+
71
+ const env = alepha.parseEnv(
72
+ t.object({
73
+ TURNSTILE_SECRET_KEY: t.text({
74
+ description:
75
+ "The secret key from the Cloudflare Turnstile dashboard.",
76
+ }),
77
+ }),
78
+ );
79
+
80
+ this.secretKey = env.TURNSTILE_SECRET_KEY;
81
+ }
82
+
83
+ public async verify(token: string, ip?: string): Promise<boolean> {
84
+ const body = new URLSearchParams();
85
+ body.set("secret", this.secretKey);
86
+ body.set("response", token);
87
+
88
+ if (ip) {
89
+ body.set("remoteip", ip);
90
+ }
91
+
92
+ try {
93
+ const res = await fetch(
94
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
95
+ {
96
+ method: "POST",
97
+ body,
98
+ },
99
+ );
100
+
101
+ const data = (await res.json()) as TurnstileResponse;
102
+
103
+ if (!data.success) {
104
+ this.log.debug("Turnstile verification failed", {
105
+ errorCodes: data["error-codes"],
106
+ });
107
+ }
108
+
109
+ return data.success;
110
+ } catch (error) {
111
+ throw new AlephaError("Failed to verify Turnstile captcha token", {
112
+ cause: error,
113
+ });
114
+ }
115
+ }
116
+ }
117
+
118
+ interface TurnstileResponse {
119
+ success: boolean;
120
+ "error-codes"?: string[];
121
+ challenge_ts?: string;
122
+ hostname?: string;
123
+ action?: string;
124
+ cdata?: string;
125
+ }
@@ -207,6 +207,63 @@ export const buildOptions = $atom({
207
207
  }),
208
208
  ),
209
209
 
210
+ /**
211
+ * PWA (Progressive Web App) configuration.
212
+ *
213
+ * Generates a web app manifest and enables installability.
214
+ * Requires a client-side bundle (React).
215
+ */
216
+ pwa: t.optional(
217
+ t.object({
218
+ /**
219
+ * Full application name displayed on the splash screen
220
+ * and in the OS app switcher.
221
+ */
222
+ name: t.string(),
223
+
224
+ /**
225
+ * Short name displayed on the home screen icon.
226
+ * Falls back to `name` if omitted.
227
+ */
228
+ shortName: t.optional(t.string()),
229
+
230
+ /**
231
+ * Theme color used for the browser toolbar and OS chrome.
232
+ *
233
+ * @default "#ffffff"
234
+ */
235
+ themeColor: t.optional(t.string()),
236
+
237
+ /**
238
+ * Background color for the splash screen.
239
+ *
240
+ * @default "#ffffff"
241
+ */
242
+ backgroundColor: t.optional(t.string()),
243
+
244
+ /**
245
+ * Display mode for the installed PWA.
246
+ *
247
+ * - `standalone` - Looks like a native app (default)
248
+ * - `fullscreen` - Uses entire screen (games, immersive)
249
+ * - `minimal-ui` - Like standalone with minimal browser UI
250
+ * - `browser` - Standard browser tab
251
+ *
252
+ * @default "standalone"
253
+ */
254
+ display: t.optional(
255
+ t.enum(["standalone", "fullscreen", "minimal-ui", "browser"]),
256
+ ),
257
+
258
+ /**
259
+ * Enable offline support via service worker.
260
+ *
261
+ * TODO: Not yet implemented.
262
+ */
263
+ offline: t.optional(t.boolean()),
264
+ }),
265
+ ),
266
+
210
267
  /**
211
268
  * Sitemap generation configuration.
212
269
  */
@@ -17,6 +17,7 @@ import { BuildCloudflareTask } from "../tasks/BuildCloudflareTask.ts";
17
17
  import { BuildCompressTask } from "../tasks/BuildCompressTask.ts";
18
18
  import { BuildDockerTask } from "../tasks/BuildDockerTask.ts";
19
19
  import { BuildPrerenderTask } from "../tasks/BuildPrerenderTask.ts";
20
+ import { BuildPwaTask } from "../tasks/BuildPwaTask.ts";
20
21
  import { BuildServerTask } from "../tasks/BuildServerTask.ts";
21
22
  import { BuildSitemapTask } from "../tasks/BuildSitemapTask.ts";
22
23
  import { BuildStaticTask } from "../tasks/BuildStaticTask.ts";
@@ -43,6 +44,7 @@ export class BuildCommand {
43
44
  $inject(BuildServerTask),
44
45
  $inject(BuildAssetsTask),
45
46
  $inject(BuildSitemapTask),
47
+ $inject(BuildPwaTask),
46
48
  $inject(BuildPrerenderTask),
47
49
  $inject(BuildVercelTask),
48
50
  $inject(BuildCloudflareTask),
@@ -594,7 +594,7 @@ if (import.meta.hot) {
594
594
  </script>`);
595
595
 
596
596
  if (style) {
597
- tags.push(`<link rel="stylesheet" href="/${style}">`);
597
+ tags.push(`<script type="module">import "/${style}";</script>`);
598
598
  }
599
599
  if (browser) {
600
600
  tags.push(`<script type="module" src="/${browser}"></script>`);
@@ -359,16 +359,19 @@ export class ViteUtils {
359
359
  // HTML template
360
360
  // ---------------------------------------------------------------------------------------------------------------
361
361
 
362
- public generateIndexHtml(entry: AppEntry): string {
362
+ public generateIndexHtml(entry: AppEntry, opts?: { pwa?: boolean }): string {
363
363
  const style = entry.style;
364
364
  const browser = entry.browser ?? entry.server;
365
+ const manifestLink = opts?.pwa
366
+ ? '\n<link rel="manifest" href="/manifest.webmanifest" />'
367
+ : "";
365
368
  return `
366
369
  <!DOCTYPE html>
367
370
  <html lang="en">
368
371
  <head>
369
372
  <meta charset="UTF-8" />
370
373
  <title>App</title>
371
- <meta name="viewport" content="width=device-width, initial-scale=1"/>
374
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>${manifestLink}
372
375
  ${style ? `<link rel="stylesheet" href="/${style}" />` : ""}
373
376
  </head>
374
377
  <body>
@@ -29,7 +29,9 @@ export class BuildClientTask extends BuildTask {
29
29
  const isCI = this.alepha.isCI();
30
30
 
31
31
  // Write index.html template for Vite to consume
32
- const template = this.viteUtils.generateIndexHtml(ctx.entry);
32
+ const template = this.viteUtils.generateIndexHtml(ctx.entry, {
33
+ pwa: !!ctx.options.pwa,
34
+ });
33
35
  await this.fs.mkdir(this.fs.join(ctx.root, "node_modules/.alepha"));
34
36
  const indexHtmlPath = this.fs.join(
35
37
  ctx.root,
@@ -141,11 +141,13 @@ export class BuildCloudflareTask extends BuildTask {
141
141
 
142
142
  const [dbName, id] = url.replace("d1://", "").replace("d1:", "").split(":");
143
143
  const binding = BuildCloudflareTask.D1_BINDING;
144
+ const jurisdiction = process.env.CLOUDFLARE_JURISDICTION;
144
145
  wrangler.d1_databases = wrangler.d1_databases || [];
145
146
  wrangler.d1_databases.push({
146
147
  binding,
147
148
  database_name: dbName,
148
149
  database_id: id,
150
+ ...(jurisdiction ? { jurisdiction } : {}),
149
151
  });
150
152
  wrangler.vars ??= {};
151
153
  wrangler.vars.DATABASE_URL = `d1://${binding}`;
@@ -177,10 +179,12 @@ export class BuildCloudflareTask extends BuildTask {
177
179
  return;
178
180
  }
179
181
 
182
+ const jurisdiction = process.env.CLOUDFLARE_JURISDICTION;
180
183
  wrangler.r2_buckets = wrangler.r2_buckets || [];
181
184
  wrangler.r2_buckets.push({
182
185
  binding: bucketName,
183
186
  bucket_name: bucketName,
187
+ ...(jurisdiction ? { jurisdiction } : {}),
184
188
  });
185
189
  wrangler.vars ??= {};
186
190
  wrangler.vars.R2_BUCKET_NAME = bucketName;
@@ -0,0 +1,81 @@
1
+ import { $inject } from "alepha";
2
+ import { FileSystemProvider } from "alepha/system";
3
+ import { BuildTask, type BuildTaskContext } from "./BuildTask.ts";
4
+
5
+ /**
6
+ * Generate PWA web app manifest.
7
+ *
8
+ * Produces a `manifest.webmanifest` in the public output directory
9
+ * from the `pwa` section of build options. Detects icons from `public/`.
10
+ */
11
+ export class BuildPwaTask extends BuildTask {
12
+ protected readonly fs = $inject(FileSystemProvider);
13
+
14
+ async run(ctx: BuildTaskContext): Promise<void> {
15
+ const pwa = ctx.options.pwa;
16
+ if (!pwa || !ctx.hasClient) {
17
+ return;
18
+ }
19
+
20
+ const distDir = ctx.options.output?.dist ?? "dist";
21
+ const publicDir = ctx.options.output?.public ?? "public";
22
+ const outputDir = this.fs.join(ctx.root, distDir, publicDir);
23
+
24
+ await ctx.run({
25
+ name: "generate pwa manifest",
26
+ handler: async () => {
27
+ const icons = await this.detectIcons(outputDir);
28
+
29
+ const manifest: Record<string, unknown> = {
30
+ name: pwa.name,
31
+ short_name: pwa.shortName ?? pwa.name,
32
+ start_url: "/",
33
+ display: pwa.display ?? "standalone",
34
+ theme_color: pwa.themeColor ?? "#ffffff",
35
+ background_color: pwa.backgroundColor ?? "#ffffff",
36
+ };
37
+
38
+ if (icons.length > 0) {
39
+ manifest.icons = icons;
40
+ }
41
+
42
+ const output = this.fs.join(outputDir, "manifest.webmanifest");
43
+ await this.fs.writeFile(output, JSON.stringify(manifest, null, 2));
44
+ },
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Detect icon files in the public output directory.
50
+ *
51
+ * Looks for common icon filenames and generates
52
+ * manifest icon entries with appropriate sizes and types.
53
+ */
54
+ protected async detectIcons(
55
+ publicDir: string,
56
+ ): Promise<Array<{ src: string; sizes: string; type: string }>> {
57
+ const icons: Array<{ src: string; sizes: string; type: string }> = [];
58
+
59
+ const candidates: Array<{
60
+ file: string;
61
+ sizes: string;
62
+ type: string;
63
+ }> = [
64
+ { file: "icon-192.png", sizes: "192x192", type: "image/png" },
65
+ { file: "icon-512.png", sizes: "512x512", type: "image/png" },
66
+ { file: "icon.svg", sizes: "any", type: "image/svg+xml" },
67
+ ];
68
+
69
+ for (const candidate of candidates) {
70
+ if (await this.fs.exists(this.fs.join(publicDir, candidate.file))) {
71
+ icons.push({
72
+ src: `/${candidate.file}`,
73
+ sizes: candidate.sizes,
74
+ type: candidate.type,
75
+ });
76
+ }
77
+ }
78
+
79
+ return icons;
80
+ }
81
+ }
@@ -48,6 +48,17 @@ export class CloudflareAdapter extends PlatformAdapter {
48
48
  return !!dbUrl?.startsWith("postgres:");
49
49
  }
50
50
 
51
+ /**
52
+ * Propagate the environment's data-jurisdiction setting to the API client.
53
+ *
54
+ * Must be invoked at the top of every entry point (authenticate, build,
55
+ * deploy, secrets, provision, migrate, inspect, teardown) because
56
+ * CloudflareApi is a singleton reused across env invocations.
57
+ */
58
+ protected configureApi(ctx: PlatformContext): void {
59
+ this.api.setJurisdiction(ctx.envConfig.jurisdiction);
60
+ }
61
+
51
62
  protected async runShell(
52
63
  command: string,
53
64
  options: Parameters<ShellProvider["run"]>[1] = {},
@@ -70,6 +81,7 @@ export class CloudflareAdapter extends PlatformAdapter {
70
81
  // -------------------------------------------------------------------------
71
82
 
72
83
  async authenticate(ctx: PlatformContext, run: RunnerMethod): Promise<void> {
84
+ this.configureApi(ctx);
73
85
  await run({
74
86
  name: "authenticate",
75
87
  handler: async () => {
@@ -112,6 +124,7 @@ export class CloudflareAdapter extends PlatformAdapter {
112
124
  // -------------------------------------------------------------------------
113
125
 
114
126
  async build(ctx: AppContext, run: RunnerMethod): Promise<void> {
127
+ this.configureApi(ctx);
115
128
  const appDir = ctx.app.path
116
129
  ? this.fs.join(ctx.root, ctx.app.path)
117
130
  : ctx.root;
@@ -159,6 +172,10 @@ export class CloudflareAdapter extends PlatformAdapter {
159
172
  env.CLOUDFLARE_DOMAIN = ctx.envConfig.domain;
160
173
  }
161
174
 
175
+ if (ctx.envConfig.jurisdiction) {
176
+ env.CLOUDFLARE_JURISDICTION = ctx.envConfig.jurisdiction;
177
+ }
178
+
162
179
  await run({
163
180
  name: "alepha build -t cloudflare",
164
181
  handler: async () => {
@@ -178,6 +195,7 @@ export class CloudflareAdapter extends PlatformAdapter {
178
195
  ctx: AppContext,
179
196
  run: RunnerMethod,
180
197
  ): Promise<string | undefined> {
198
+ this.configureApi(ctx);
181
199
  const workerName = ctx.naming.worker(
182
200
  ctx.apps.length > 1 ? ctx.app.name : undefined,
183
201
  );
@@ -212,6 +230,7 @@ export class CloudflareAdapter extends PlatformAdapter {
212
230
  "DATABASE_URL",
213
231
  "R2_BUCKET_NAME",
214
232
  "CLOUDFLARE_DOMAIN",
233
+ "CLOUDFLARE_JURISDICTION",
215
234
  "HYPERDRIVE_ID",
216
235
  "POSTGRES_SCHEMA",
217
236
  "NODE_ENV",
@@ -221,6 +240,7 @@ export class CloudflareAdapter extends PlatformAdapter {
221
240
  ctx: PlatformContext,
222
241
  run: RunnerMethod,
223
242
  ): Promise<void> {
243
+ this.configureApi(ctx);
224
244
  const envVars = await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`]);
225
245
 
226
246
  // Filter out binding/build vars, VITE_* vars, and empty values
@@ -265,6 +285,7 @@ export class CloudflareAdapter extends PlatformAdapter {
265
285
  ctx: PlatformContext,
266
286
  run: RunnerMethod,
267
287
  ): Promise<void> {
288
+ this.configureApi(ctx);
268
289
  const needsDB = ctx.apps.some((a) => a.resources.hasDatabase);
269
290
  const needsBucket = ctx.apps.some((a) => a.resources.hasBucket);
270
291
  const postgres = needsDB && (await this.isPostgres(ctx));
@@ -345,6 +366,7 @@ export class CloudflareAdapter extends PlatformAdapter {
345
366
  ctx: PlatformContext,
346
367
  run: RunnerMethod,
347
368
  ): Promise<void> {
369
+ this.configureApi(ctx);
348
370
  const needsDB = ctx.apps.some((a) => a.resources.hasDatabase);
349
371
  if (!needsDB) {
350
372
  return;
@@ -431,6 +453,7 @@ export class CloudflareAdapter extends PlatformAdapter {
431
453
  ctx: PlatformContext,
432
454
  run: RunnerMethod,
433
455
  ): Promise<PlatformState> {
456
+ this.configureApi(ctx);
434
457
  const state: PlatformState = {
435
458
  workers: [],
436
459
  databases: [],
@@ -610,6 +633,7 @@ export class CloudflareAdapter extends PlatformAdapter {
610
633
  // -------------------------------------------------------------------------
611
634
 
612
635
  async teardown(ctx: PlatformContext, run: RunnerMethod): Promise<void> {
636
+ this.configureApi(ctx);
613
637
  // 1. Remove queue consumers (must happen before worker or queue deletion)
614
638
  for (const app of ctx.apps) {
615
639
  if (app.resources.hasQueue) {
@@ -53,11 +53,27 @@ export const platformOptions = $atom({
53
53
  * Named environments with their adapter and configuration.
54
54
  */
55
55
  environments: t.record(
56
- t.text(),
56
+ t.text({
57
+ description:
58
+ "Environment name (e.g. 'production', 'staging', 'preview'). Used in resource naming and selected via --env.",
59
+ }),
57
60
  t.object({
58
61
  adapter: t.enum(["cloudflare", "vercel"]),
62
+ /**
63
+ * Custom domain for the deployed worker (e.g. "api.example.com").
64
+ *
65
+ * On Cloudflare this is attached as a custom-domain route.
66
+ * Omit to use the adapter's default `*.workers.dev` / preview URL.
67
+ */
59
68
  domain: t.optional(t.text()),
60
- domains: t.optional(t.record(t.text(), t.text())),
69
+ /**
70
+ * Cloudflare data jurisdiction for R2 buckets and D1 databases.
71
+ * - "eu": data stays within the EU
72
+ * - "fedramp": FedRAMP-authorized regions
73
+ *
74
+ * Omit for the default (global) jurisdiction.
75
+ */
76
+ jurisdiction: t.optional(t.enum(["eu", "fedramp"])),
61
77
  }),
62
78
  ),
63
79
  }),
@@ -75,6 +91,6 @@ export type PlatformOptions = Static<typeof platformOptions.schema>;
75
91
  export interface EnvironmentConfig {
76
92
  adapter: "cloudflare" | "vercel";
77
93
  domain?: string;
78
- domains?: Record<string, string>;
79
94
  vars?: Record<string, string>;
95
+ jurisdiction?: "eu" | "fedramp";
80
96
  }
@@ -0,0 +1,51 @@
1
+ import type { RunnerMethod } from "alepha/command";
2
+ import type { PlatformContext } from "../adapters/PlatformAdapter.ts";
3
+
4
+ /**
5
+ * Context passed to platform hooks.
6
+ *
7
+ * Extends PlatformContext with the fully-qualified base URL of the
8
+ * deployed app (derived from `envConfig.domain` or the adapter's deploy
9
+ * URL) and the active RunnerMethod.
10
+ */
11
+ export interface PlatformHookContext extends PlatformContext {
12
+ /**
13
+ * Fully-qualified base URL of the deployed app, e.g. "https://foo.com".
14
+ */
15
+ baseUrl: string;
16
+ run: RunnerMethod;
17
+ }
18
+
19
+ /**
20
+ * Third-party provisioning hook for `alepha platform up` / `down`.
21
+ *
22
+ * Plugins can extend this to register resources in external services
23
+ * (Stripe webhooks, Sentry projects, Resend domains, etc.) tied to a
24
+ * deployment. Hooks are discovered via `alepha.services(PlatformHook)`,
25
+ * so adding a plugin to `services: [...]` in `alepha.config.ts` is all
26
+ * it takes to wire one in.
27
+ *
28
+ * Lifecycle:
29
+ * - `register` runs after `deploy` and before `adapter.secrets()` so
30
+ * any secret a hook writes to `.env.<env>` is picked up in the same
31
+ * `up` cycle.
32
+ * - `unregister` runs on `down` before `adapter.teardown()`.
33
+ *
34
+ * Implementations MUST be idempotent: `register` is called on every `up`.
35
+ */
36
+ export abstract class PlatformHook {
37
+ /**
38
+ * Stable identifier. Used for log output.
39
+ */
40
+ abstract readonly name: string;
41
+
42
+ /**
43
+ * Create or update external resources. Must tolerate pre-existing state.
44
+ */
45
+ abstract register(ctx: PlatformHookContext): Promise<void>;
46
+
47
+ /**
48
+ * Remove external resources. Must tolerate missing state.
49
+ */
50
+ abstract unregister(ctx: PlatformHookContext): Promise<void>;
51
+ }
@@ -101,6 +101,7 @@ export * from "./adapters/VercelAdapter.ts";
101
101
  export * from "./atoms/platformOptions.ts";
102
102
  export * from "./commands/platform.ts";
103
103
  export * from "./commands/SecretsCommand.ts";
104
+ export * from "./hooks/PlatformHook.ts";
104
105
  export * from "./providers/GitHubSecretStore.ts";
105
106
  export * from "./providers/MemorySecretStore.ts";
106
107
  export * from "./providers/PlatformCacheProvider.ts";
@@ -67,6 +67,19 @@ export class CloudflareApi {
67
67
 
68
68
  protected token?: string;
69
69
  protected accountId?: string;
70
+ protected jurisdiction?: "eu" | "fedramp";
71
+
72
+ /**
73
+ * Set the Cloudflare data jurisdiction for R2 and D1 resources.
74
+ *
75
+ * R2 buckets and D1 databases created under a jurisdiction live in a
76
+ * separate namespace — every R2 API call (list/create/delete) must include
77
+ * the `cf-r2-jurisdiction` header, and D1 create must include the field
78
+ * in the request body. Omit / pass `undefined` for the default (global).
79
+ */
80
+ public setJurisdiction(jurisdiction?: "eu" | "fedramp"): void {
81
+ this.jurisdiction = jurisdiction;
82
+ }
70
83
 
71
84
  // -------------------------------------------------------------------------
72
85
  // Auth
@@ -127,7 +140,11 @@ export class CloudflareApi {
127
140
  `/accounts/${accountId}/d1/database`,
128
141
  {
129
142
  method: "POST",
130
- body: { name, primary_location_hint: location },
143
+ body: {
144
+ name,
145
+ primary_location_hint: location,
146
+ ...(this.jurisdiction ? { jurisdiction: this.jurisdiction } : {}),
147
+ },
131
148
  bodySchema: createD1BodySchema,
132
149
  schema: cloudflareD1Schema,
133
150
  },
@@ -392,6 +409,10 @@ export class CloudflareApi {
392
409
  Authorization: `Bearer ${token}`,
393
410
  };
394
411
 
412
+ if (this.jurisdiction && /\/r2\//.test(path)) {
413
+ headers["cf-r2-jurisdiction"] = this.jurisdiction;
414
+ }
415
+
395
416
  const init: RequestInit = { method, headers };
396
417
 
397
418
  if (body) {