alepha 0.22.0 → 0.23.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.
Files changed (111) hide show
  1. package/dist/api/jobs/index.d.ts +20 -20
  2. package/dist/api/jobs/index.d.ts.map +1 -1
  3. package/dist/api/keys/index.d.ts +6 -6
  4. package/dist/api/users/index.d.ts +43 -9
  5. package/dist/api/users/index.d.ts.map +1 -1
  6. package/dist/api/users/index.js +24 -3
  7. package/dist/api/users/index.js.map +1 -1
  8. package/dist/api/verifications/index.d.ts +13 -13
  9. package/dist/cli/core/index.d.ts +46 -40
  10. package/dist/cli/core/index.d.ts.map +1 -1
  11. package/dist/cli/core/index.js +51 -101
  12. package/dist/cli/core/index.js.map +1 -1
  13. package/dist/cli/i18n/index.d.ts +12 -5
  14. package/dist/cli/i18n/index.d.ts.map +1 -1
  15. package/dist/cli/i18n/index.js +45 -11
  16. package/dist/cli/i18n/index.js.map +1 -1
  17. package/dist/cli/platform-lib/index.d.ts +32 -6
  18. package/dist/cli/platform-lib/index.d.ts.map +1 -1
  19. package/dist/cli/platform-lib/index.js +82 -19
  20. package/dist/cli/platform-lib/index.js.map +1 -1
  21. package/dist/command/index.d.ts +1 -1
  22. package/dist/mcp/index.d.ts +9 -0
  23. package/dist/mcp/index.d.ts.map +1 -1
  24. package/dist/mcp/index.js +23 -0
  25. package/dist/mcp/index.js.map +1 -1
  26. package/dist/react/form/index.d.ts +0 -1
  27. package/dist/react/form/index.d.ts.map +1 -1
  28. package/dist/react/form/index.js +16 -15
  29. package/dist/react/form/index.js.map +1 -1
  30. package/dist/react/i18n/index.d.ts +43 -0
  31. package/dist/react/i18n/index.d.ts.map +1 -1
  32. package/dist/react/i18n/index.js +114 -10
  33. package/dist/react/i18n/index.js.map +1 -1
  34. package/dist/react/router/index.browser.js +128 -5
  35. package/dist/react/router/index.browser.js.map +1 -1
  36. package/dist/react/router/index.d.ts +108 -1
  37. package/dist/react/router/index.d.ts.map +1 -1
  38. package/dist/react/router/index.js +184 -6
  39. package/dist/react/router/index.js.map +1 -1
  40. package/dist/react/sitemap/index.browser.js +35 -0
  41. package/dist/react/sitemap/index.browser.js.map +1 -0
  42. package/dist/react/sitemap/index.d.ts +92 -0
  43. package/dist/react/sitemap/index.d.ts.map +1 -0
  44. package/dist/react/sitemap/index.js +131 -0
  45. package/dist/react/sitemap/index.js.map +1 -0
  46. package/dist/server/auth/index.d.ts +105 -1
  47. package/dist/server/auth/index.d.ts.map +1 -1
  48. package/dist/server/auth/index.js +1604 -7
  49. package/dist/server/auth/index.js.map +1 -1
  50. package/dist/server/cookies/index.d.ts +15 -0
  51. package/dist/server/cookies/index.d.ts.map +1 -1
  52. package/dist/server/cookies/index.js +22 -3
  53. package/dist/server/cookies/index.js.map +1 -1
  54. package/dist/server/core/index.d.ts +18 -0
  55. package/dist/server/core/index.d.ts.map +1 -1
  56. package/dist/server/core/index.js +25 -0
  57. package/dist/server/core/index.js.map +1 -1
  58. package/package.json +16 -3
  59. package/src/api/users/controllers/RealmController.ts +1 -0
  60. package/src/api/users/primitives/$realm.ts +26 -0
  61. package/src/api/users/providers/RealmProvider.ts +15 -0
  62. package/src/api/users/schemas/realmConfigSchema.ts +14 -0
  63. package/src/cli/core/atoms/buildOptions.ts +0 -12
  64. package/src/cli/core/commands/build.ts +0 -10
  65. package/src/cli/core/index.ts +0 -3
  66. package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
  67. package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
  68. package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
  69. package/src/cli/i18n/services/I18nCheckService.ts +65 -11
  70. package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
  71. package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
  72. package/src/mcp/providers/McpServerProvider.ts +55 -0
  73. package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
  74. package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
  75. package/src/react/form/services/FormModel.ts +57 -39
  76. package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
  77. package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
  78. package/src/react/i18n/providers/I18nProvider.ts +171 -12
  79. package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
  80. package/src/react/router/index.browser.ts +4 -0
  81. package/src/react/router/index.shared.ts +1 -0
  82. package/src/react/router/index.ts +9 -0
  83. package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
  84. package/src/react/router/providers/ReactPageProvider.ts +12 -1
  85. package/src/react/router/providers/ReactServerProvider.ts +92 -1
  86. package/src/react/router/providers/RootComponentsProvider.ts +13 -0
  87. package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
  88. package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
  89. package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
  90. package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
  91. package/src/react/sitemap/index.browser.ts +21 -0
  92. package/src/react/sitemap/index.ts +25 -0
  93. package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
  94. package/src/react/sitemap/primitives/$sitemap.ts +196 -0
  95. package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
  96. package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
  97. package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
  98. package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
  99. package/src/server/auth/helpers/appleClientSecret.ts +24 -0
  100. package/src/server/auth/helpers/federationAssertion.ts +74 -0
  101. package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
  102. package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
  103. package/src/server/auth/index.ts +4 -0
  104. package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
  105. package/src/server/auth/primitives/$authFederationClient.ts +89 -0
  106. package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
  107. package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
  108. package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
  109. package/src/server/core/interfaces/ServerRequest.ts +8 -0
  110. package/src/server/core/primitives/$route.ts +27 -0
  111. package/src/cli/core/tasks/BuildSitemapTask.ts +0 -130
@@ -0,0 +1,273 @@
1
+ import { AlephaError, t } from "alepha";
2
+ import { SecurityError } from "alepha/security";
3
+ import { $route, BadRequestError } from "alepha/server";
4
+ import { $cookie } from "alepha/server/cookies";
5
+ import {
6
+ authorizationCodeGrant,
7
+ buildAuthorizationUrl,
8
+ type Configuration,
9
+ calculatePKCECodeChallenge,
10
+ discovery,
11
+ randomPKCECodeVerifier,
12
+ randomState,
13
+ } from "openid-client";
14
+ import { signAppleClientSecret } from "../helpers/appleClientSecret.ts";
15
+ import {
16
+ type FederationProfile,
17
+ signFederationAssertion,
18
+ } from "../helpers/federationAssertion.ts";
19
+ import { safeRedirectPath } from "../helpers/safeRedirectPath.ts";
20
+
21
+ export interface FederationBrokerProviders {
22
+ google?: { clientId: string; clientSecret: string };
23
+ apple?: {
24
+ serviceId: string;
25
+ teamId: string;
26
+ keyId: string;
27
+ privateKeyPem: string;
28
+ };
29
+ }
30
+
31
+ export interface FederationBrokerOptions {
32
+ /** Broker public origin, e.g. https://alepha.club — becomes the assertion `iss`. */
33
+ issuer: string;
34
+ /** EdDSA PKCS#8 PEM — signs assertions. */
35
+ signingKeyPem: string;
36
+ providers: FederationBrokerProviders;
37
+ /** Validate the requested tenant and return its exact origin (or null to reject). */
38
+ resolveTenant: (tenant: string) => Promise<string | null>;
39
+ assertionTtlSeconds?: number;
40
+ }
41
+
42
+ const ISSUERS = {
43
+ google: "https://accounts.google.com",
44
+ apple: "https://appleid.apple.com",
45
+ } as const;
46
+
47
+ export const $authFederationBroker = (options: FederationBrokerOptions) => {
48
+ const callbackPath = "/auth/federated/callback";
49
+
50
+ if (!options.signingKeyPem) {
51
+ throw new AlephaError("$authFederationBroker requires signingKeyPem");
52
+ }
53
+
54
+ // Per-flow cookie: carries the tenant + PKCE/state across the redirect.
55
+ const flow = $cookie({
56
+ name: "federationFlow",
57
+ ttl: [15, "minutes"],
58
+ httpOnly: true,
59
+ encrypt: true,
60
+ schema: t.object({
61
+ provider: t.text(),
62
+ tenantOrigin: t.text({ size: "long" }),
63
+ redirectPath: t.text({ size: "long" }),
64
+ codeVerifier: t.optional(t.text({ size: "long" })),
65
+ state: t.optional(t.text()),
66
+ nonce: t.optional(t.text()),
67
+ }),
68
+ });
69
+
70
+ const callbackUri = `${options.issuer}${callbackPath}`;
71
+
72
+ // Build an openid-client Configuration for a provider. Apple's client_secret
73
+ // is signed fresh on every call (~5min) — no static secret, no rotation.
74
+ const getConfig = async (
75
+ provider: "google" | "apple",
76
+ ): Promise<Configuration> => {
77
+ if (provider === "google") {
78
+ const g = options.providers.google;
79
+ if (!g) {
80
+ throw new SecurityError("google federation not configured");
81
+ }
82
+ return discovery(new URL(ISSUERS.google), g.clientId, g.clientSecret);
83
+ }
84
+ const a = options.providers.apple;
85
+ if (!a) {
86
+ throw new SecurityError("apple federation not configured");
87
+ }
88
+ const clientSecret = await signAppleClientSecret({
89
+ privateKeyPem: a.privateKeyPem,
90
+ teamId: a.teamId,
91
+ serviceId: a.serviceId,
92
+ keyId: a.keyId,
93
+ });
94
+ return discovery(new URL(ISSUERS.apple), a.serviceId, clientSecret);
95
+ };
96
+
97
+ const scopeFor = (provider: string) =>
98
+ provider === "apple" ? "name email" : "openid email profile";
99
+
100
+ const start = $route({
101
+ path: "/auth/federated/start",
102
+ schema: {
103
+ query: t.object({
104
+ provider: t.text(),
105
+ tenant: t.text(),
106
+ redirect: t.optional(t.text({ size: "long" })),
107
+ }),
108
+ },
109
+ handler: async ({ query, reply, cookies }) => {
110
+ if (query.provider !== "google" && query.provider !== "apple") {
111
+ throw new BadRequestError(`Unsupported provider '${query.provider}'`);
112
+ }
113
+ const tenantOrigin = await options.resolveTenant(query.tenant);
114
+ if (!tenantOrigin) {
115
+ throw new BadRequestError("Unknown or inactive tenant");
116
+ }
117
+
118
+ const config = await getConfig(query.provider);
119
+ const codeVerifier = randomPKCECodeVerifier();
120
+ const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
121
+ const parameters: Record<string, string> = {
122
+ redirect_uri: callbackUri,
123
+ scope: scopeFor(query.provider),
124
+ code_challenge: codeChallenge,
125
+ code_challenge_method: "S256",
126
+ };
127
+ // Apple needs response_mode=form_post when requesting name/email scopes.
128
+ if (query.provider === "apple") {
129
+ parameters.response_mode = "form_post";
130
+ }
131
+
132
+ const usePkce = config.serverMetadata().supportsPKCE();
133
+ let state: string | undefined;
134
+ let nonce: string | undefined;
135
+ if (!usePkce) {
136
+ state = randomState();
137
+ nonce = randomState();
138
+ parameters.state = state;
139
+ parameters.nonce = nonce;
140
+ delete parameters.code_challenge;
141
+ delete parameters.code_challenge_method;
142
+ }
143
+
144
+ flow.set(
145
+ {
146
+ provider: query.provider,
147
+ tenantOrigin,
148
+ redirectPath: safeRedirectPath(query.redirect),
149
+ codeVerifier: usePkce ? codeVerifier : undefined,
150
+ state,
151
+ nonce,
152
+ },
153
+ { cookies },
154
+ );
155
+ reply.redirect(buildAuthorizationUrl(config, parameters).toString(), 302);
156
+ },
157
+ });
158
+
159
+ const handle = async (
160
+ urlOrReq: URL | Request,
161
+ cookies: any,
162
+ reply: any,
163
+ rawProfile?: Record<string, unknown>,
164
+ ) => {
165
+ const ctx = flow.get({ cookies });
166
+ if (!ctx) {
167
+ throw new BadRequestError("Missing federation flow");
168
+ }
169
+ flow.del({ cookies });
170
+
171
+ const provider = ctx.provider as "google" | "apple";
172
+
173
+ let profile: FederationProfile;
174
+ try {
175
+ const config = await getConfig(provider);
176
+ const tokens = await authorizationCodeGrant(config, urlOrReq, {
177
+ pkceCodeVerifier: ctx.codeVerifier,
178
+ expectedState: ctx.state,
179
+ expectedNonce: ctx.nonce,
180
+ });
181
+
182
+ // Verified claims come from the id_token; merge Apple's one-time form_post name.
183
+ const claims = (tokens.claims?.() ?? {}) as Record<string, unknown>;
184
+ const merged = { ...rawProfile, ...claims } as Record<string, unknown>;
185
+ profile = {
186
+ provider,
187
+ sub: String(merged.sub),
188
+ email: merged.email as string | undefined,
189
+ email_verified:
190
+ typeof merged.email_verified === "string"
191
+ ? merged.email_verified === "true"
192
+ : (merged.email_verified as boolean | undefined),
193
+ name: merged.name as string | undefined,
194
+ given_name: merged.given_name as string | undefined,
195
+ family_name: merged.family_name as string | undefined,
196
+ picture: merged.picture as string | undefined,
197
+ is_private_email:
198
+ typeof merged.is_private_email === "string"
199
+ ? merged.is_private_email === "true"
200
+ : (merged.is_private_email as boolean | undefined),
201
+ };
202
+ } catch {
203
+ // Upstream auth failed or the user denied consent — bounce back to the
204
+ // tenant with an error rather than surfacing a raw 500.
205
+ const fail = new URL(`${ctx.tenantOrigin}${ctx.redirectPath}`);
206
+ fail.searchParams.set("error", "federation_failed");
207
+ reply.redirect(fail.toString(), 302);
208
+ return;
209
+ }
210
+
211
+ const assertion = await signFederationAssertion(profile, {
212
+ privateKeyPem: options.signingKeyPem,
213
+ issuer: options.issuer,
214
+ audience: ctx.tenantOrigin,
215
+ ttlSeconds: options.assertionTtlSeconds,
216
+ });
217
+
218
+ const dest = new URL(`${ctx.tenantOrigin}/auth/federated/callback`);
219
+ dest.searchParams.set("token", assertion);
220
+ dest.searchParams.set("redirect", ctx.redirectPath);
221
+ reply.redirect(dest.toString(), 302);
222
+ };
223
+
224
+ const callback = $route({
225
+ path: callbackPath,
226
+ handler: async ({ url, reply, cookies }) => handle(url, cookies, reply),
227
+ });
228
+
229
+ // Apple posts the result (form_post). Extract its one-time `user` (name/email)
230
+ // before openid-client consumes the body.
231
+ const callbackPost = $route({
232
+ path: callbackPath,
233
+ method: "POST",
234
+ handler: async ({ reply, cookies, raw }) => {
235
+ let rawProfile: Record<string, unknown> | undefined;
236
+ let req: Request | URL = raw?.web?.req as Request;
237
+ if (raw?.web?.req) {
238
+ const cloned = raw.web.req.clone();
239
+ req = raw.web.req;
240
+ try {
241
+ const form = await cloned.formData();
242
+ const userField = form.get("user");
243
+ if (typeof userField === "string") {
244
+ const parsed = JSON.parse(userField) as {
245
+ name?: { firstName?: string; lastName?: string };
246
+ email?: string;
247
+ };
248
+ rawProfile = {};
249
+ if (parsed.name?.firstName) {
250
+ rawProfile.given_name = parsed.name.firstName;
251
+ }
252
+ if (parsed.name?.lastName) {
253
+ rawProfile.family_name = parsed.name.lastName;
254
+ }
255
+ if (parsed.name?.firstName || parsed.name?.lastName) {
256
+ rawProfile.name = [parsed.name?.firstName, parsed.name?.lastName]
257
+ .filter(Boolean)
258
+ .join(" ");
259
+ }
260
+ if (parsed.email) {
261
+ rawProfile.email = parsed.email;
262
+ }
263
+ }
264
+ } catch {
265
+ // ignore — name is optional on repeat logins
266
+ }
267
+ }
268
+ await handle(req, cookies, reply, rawProfile);
269
+ },
270
+ });
271
+
272
+ return { start, callback, callbackPost };
273
+ };
@@ -0,0 +1,89 @@
1
+ import { $context, t } from "alepha";
2
+ import type { IssuerPrimitive } from "alepha/security";
3
+ import { $route, BadRequestError } from "alepha/server";
4
+ import {
5
+ type VerifyAssertionOptions,
6
+ verifyFederationAssertion,
7
+ } from "../helpers/federationAssertion.ts";
8
+ import { JtiReplayGuard } from "../helpers/jtiReplayGuard.ts";
9
+ import { safeRedirectPath } from "../helpers/safeRedirectPath.ts";
10
+ import { ServerAuthProvider } from "../providers/ServerAuthProvider.ts";
11
+ import type { LinkAccountOptions, WithLinkFn } from "./$auth.ts";
12
+
13
+ export async function assertionToProfile(
14
+ token: string,
15
+ opts: VerifyAssertionOptions,
16
+ ): Promise<{ provider: string; jti: string; link: LinkAccountOptions }> {
17
+ const { profile, jti } = await verifyFederationAssertion(token, opts);
18
+ return {
19
+ provider: profile.provider,
20
+ jti,
21
+ link: {
22
+ access_token: "", // federated: no upstream token retained
23
+ user: {
24
+ sub: profile.sub,
25
+ email: profile.email,
26
+ email_verified: profile.email_verified,
27
+ name: profile.name,
28
+ given_name: profile.given_name,
29
+ family_name: profile.family_name,
30
+ picture: profile.picture,
31
+ },
32
+ },
33
+ };
34
+ }
35
+
36
+ export interface FederationClientOptions {
37
+ realm: IssuerPrimitive & WithLinkFn;
38
+ brokerUrl: string; // assertion issuer
39
+ publicKeyPem: string;
40
+ selfOrigin?: string; // optional override; otherwise derived from the request host
41
+ }
42
+
43
+ export const $authFederationClient = (options: FederationClientOptions) => {
44
+ const { alepha } = $context();
45
+ const replay = new JtiReplayGuard(); // single-use, bounded (assertions ~60s)
46
+
47
+ const callback = $route({
48
+ path: "/auth/federated/callback",
49
+ schema: {
50
+ query: t.object({
51
+ token: t.text({ size: "rich" }),
52
+ redirect: t.optional(t.text({ size: "long" })),
53
+ }),
54
+ },
55
+ handler: async ({ query, url, reply, cookies }) => {
56
+ const serverAuth = alepha.inject(ServerAuthProvider);
57
+ const audience = options.selfOrigin ?? `${url.protocol}//${url.host}`;
58
+ try {
59
+ const { provider, jti, link } = await assertionToProfile(query.token, {
60
+ publicKeyPem: options.publicKeyPem,
61
+ issuer: options.brokerUrl,
62
+ audience,
63
+ });
64
+ if (!replay.check(jti)) {
65
+ throw new BadRequestError("Assertion already used");
66
+ }
67
+ if (!options.realm.link) {
68
+ throw new BadRequestError("Realm has no link function");
69
+ }
70
+ const user = await options.realm.link(provider)(link);
71
+ await serverAuth.establishSession(
72
+ user,
73
+ options.realm,
74
+ provider,
75
+ cookies,
76
+ );
77
+ } catch {
78
+ // Invalid / expired / replayed / tampered assertion (or a link
79
+ // refusal, e.g. unverified email) is an expected failure mode — bounce
80
+ // to login with an error rather than surfacing a raw 500.
81
+ reply.redirect("/auth/login?error=federation_failed", 302);
82
+ return;
83
+ }
84
+ reply.redirect(safeRedirectPath(query.redirect), 302);
85
+ },
86
+ });
87
+
88
+ return { callback };
89
+ };
@@ -3,6 +3,7 @@ import { DateTimeProvider } from "alepha/datetime";
3
3
  import { $logger } from "alepha/logger";
4
4
  import {
5
5
  InvalidCredentialsError,
6
+ type IssuerPrimitive,
6
7
  SecurityError,
7
8
  type UserAccount,
8
9
  } from "alepha/security";
@@ -547,18 +548,31 @@ export class ServerAuthProvider {
547
548
  return;
548
549
  }
549
550
 
550
- const tokens = await issuer.createToken(user);
551
+ await this.establishSession(user, issuer, provider.name, cookies);
552
+
553
+ reply.redirect(redirectUri, 302);
554
+ }
551
555
 
556
+ /**
557
+ * Establish a local session for an already-resolved user: mint realm tokens
558
+ * and write the `tokens` cookie. Used by the OAuth callback and by federated
559
+ * (broker) login. `issuer` is the realm issuer (provider.issuer / realm).
560
+ */
561
+ public async establishSession(
562
+ user: UserAccount,
563
+ issuer: IssuerPrimitive,
564
+ providerName: string,
565
+ cookies: Cookies,
566
+ ): Promise<void> {
567
+ const tokens = await issuer.createToken(user);
552
568
  this.setTokens(
553
569
  {
554
570
  ...tokens,
555
571
  issued_at: this.dateTimeProvider.now().unix(),
556
- provider: provider.name,
572
+ provider: providerName,
557
573
  },
558
574
  cookies,
559
575
  );
560
-
561
- reply.redirect(redirectUri, 302);
562
576
  }
563
577
 
564
578
  /**
@@ -232,6 +232,76 @@ describe("ServerCookiesProvider", () => {
232
232
  // Secure flag is not added in tests unless protocol is https, which is handled by the provider
233
233
  });
234
234
 
235
+ describe("APP_NAME namespacing", () => {
236
+ class TokenApp {
237
+ tokens = $cookie({
238
+ name: "tokens",
239
+ schema: t.object({ value: t.text() }),
240
+ });
241
+ set = $action({
242
+ handler: () => {
243
+ this.tokens.set({ value: "from-app" });
244
+ },
245
+ });
246
+ read = $action({
247
+ schema: {
248
+ response: t.object({ value: t.optional(t.text()) }),
249
+ },
250
+ handler: ({ cookies }) => {
251
+ return { value: this.tokens.get({ cookies })?.value };
252
+ },
253
+ });
254
+ }
255
+
256
+ const makeApp = (appName: string) =>
257
+ Alepha.create({
258
+ env: { COOKIE_SECRET: TEST_COOKIE_SECRET, APP_NAME: appName },
259
+ })
260
+ .with(AlephaServer)
261
+ .with(AlephaServerCookies)
262
+ .with(TokenApp);
263
+
264
+ test("prefixes the cookie name with the lowercased APP_NAME", async () => {
265
+ const appA = makeApp("AppA");
266
+ await appA.start();
267
+
268
+ const response = await appA.inject(TokenApp).set.fetch();
269
+ const setCookieHeader = response.headers.get("set-cookie");
270
+
271
+ // Cookie is written under the namespaced name, not the bare "tokens".
272
+ expect(setCookieHeader).toContain("appa.tokens=");
273
+ expect(setCookieHeader).not.toMatch(/(^|; )tokens=/);
274
+ });
275
+
276
+ test("two apps sharing a cookie jar do not collide", async () => {
277
+ const appA = makeApp("AppA");
278
+ const appB = makeApp("AppB");
279
+ await appA.start();
280
+ await appB.start();
281
+
282
+ // App A writes its cookie.
283
+ const responseA = await appA.inject(TokenApp).set.fetch();
284
+ const cookieA = responseA.headers
285
+ .get("set-cookie")!
286
+ .match(/appa\.tokens=([^;]*)/)![1];
287
+
288
+ // App A reads its own cookie back from the shared jar — works.
289
+ const incoming = `appa.tokens=${cookieA}`;
290
+ const readA = await appA
291
+ .inject(TokenApp)
292
+ .read.fetch({}, { request: { headers: { cookie: incoming } } });
293
+ expect(readA.data.value).toBe("from-app");
294
+
295
+ // App B sees the SAME jar (same host:localhost), but reads under
296
+ // "appb.tokens" — so App A's cookie is invisible to it. No collision,
297
+ // no failed-decrypt-then-delete logout.
298
+ const readB = await appB
299
+ .inject(TokenApp)
300
+ .read.fetch({}, { request: { headers: { cookie: incoming } } });
301
+ expect(readB.data.value).toBeUndefined();
302
+ });
303
+ });
304
+
235
305
  // test("should throw if secret is missing for secure cookies", async () => {
236
306
  // class AppWithMissingSecret {
237
307
  // badCookie = $cookie({
@@ -88,13 +88,33 @@ export class ServerCookiesProvider {
88
88
  );
89
89
  }
90
90
 
91
+ /**
92
+ * Namespaces a cookie name with the app's `APP_NAME` (lowercased) so that
93
+ * two Alepha apps sharing a cookie jar do not collide.
94
+ *
95
+ * Cookies are scoped by host + path only — never by port — so two apps on
96
+ * `localhost:3000` and `localhost:4000` would otherwise both read/write a
97
+ * cookie literally named `tokens` and silently clobber each other (and,
98
+ * because each encrypts with its own `APP_SECRET`, the foreign cookie fails
99
+ * to decrypt and gets deleted, logging the user out).
100
+ *
101
+ * When `APP_NAME` is unset the name is returned unchanged, so existing
102
+ * single-app deployments keep their current cookie names. Same convention as
103
+ * the `APP_NAME` prefix used by R2 storage and server-timing.
104
+ */
105
+ protected prefixName(name: string): string {
106
+ const app = this.alepha.env.APP_NAME;
107
+ if (!app) return name;
108
+ return `${String(app).toLowerCase()}.${name}`;
109
+ }
110
+
91
111
  public getCookie<T extends TSchema>(
92
112
  name: string,
93
113
  options: CookiePrimitiveOptions<T>,
94
114
  contextCookies?: Cookies,
95
115
  ): Static<T> | undefined {
96
116
  const cookies = this.getCookiesFromContext(contextCookies);
97
- let rawValue = cookies.req[name];
117
+ let rawValue = cookies.req[this.prefixName(name)];
98
118
 
99
119
  if (!rawValue) return undefined;
100
120
 
@@ -174,7 +194,7 @@ export class ServerCookiesProvider {
174
194
  cookie.maxAge = this.dateTimeProvider.duration(options.ttl).as("seconds");
175
195
  }
176
196
 
177
- cookies.res[name] = cookie;
197
+ cookies.res[this.prefixName(name)] = cookie;
178
198
  }
179
199
 
180
200
  public deleteCookie<T extends TSchema>(
@@ -182,7 +202,7 @@ export class ServerCookiesProvider {
182
202
  contextCookies?: Cookies,
183
203
  ): void {
184
204
  const cookies = this.getCookiesFromContext(contextCookies);
185
- cookies.res[name] = null;
205
+ cookies.res[this.prefixName(name)] = null;
186
206
  }
187
207
 
188
208
  // --- Crypto & Parsing ---
@@ -188,6 +188,14 @@ export interface ServerRoute<
188
188
  * @see ServerLoggerProvider
189
189
  */
190
190
  silent?: boolean;
191
+
192
+ /**
193
+ * Mark this `GET` route as prerenderable. When `true`, the build invokes the
194
+ * handler in-process and writes its body verbatim to `dist/public/{path}`,
195
+ * so the route is served as a static file (e.g. `sitemap.xml`, `robots.txt`).
196
+ * The route still serves live at request time for runtimes that have a server.
197
+ */
198
+ static?: boolean;
191
199
  }
192
200
 
193
201
  /**
@@ -5,9 +5,11 @@ import {
5
5
  PipelinePrimitive,
6
6
  type PipelinePrimitiveOptions,
7
7
  } from "alepha";
8
+ import { ServerReply } from "../helpers/ServerReply.ts";
8
9
  import type {
9
10
  RequestConfigSchema,
10
11
  ServerHandler,
12
+ ServerRequest,
11
13
  ServerRoute,
12
14
  } from "../interfaces/ServerRequest.ts";
13
15
  import { ServerRouterProvider } from "../providers/ServerRouterProvider.ts";
@@ -46,6 +48,31 @@ export class RoutePrimitive<
46
48
  handler: this.handler,
47
49
  } as ServerRoute<TConfig>);
48
50
  }
51
+
52
+ /**
53
+ * Invoke this route's handler in-process (no HTTP server) and return its path
54
+ * and body. Used by the build to snapshot `static` routes to files.
55
+ *
56
+ * Only meaningful for self-contained `GET` handlers that return a string or
57
+ * Buffer (e.g. `sitemap.xml`, `robots.txt`).
58
+ */
59
+ public async prerender(): Promise<{ path: string; body: string | Buffer }> {
60
+ const reply = new ServerReply();
61
+ const request = {
62
+ method: this.options.method ?? "GET",
63
+ url: new URL(`http://localhost${this.options.path}`),
64
+ params: {},
65
+ query: {},
66
+ headers: {},
67
+ body: undefined,
68
+ metadata: {},
69
+ reply,
70
+ } as unknown as ServerRequest<TConfig>;
71
+
72
+ const result = await this.handler.run(request);
73
+ const body = (result ?? reply.body) as string | Buffer;
74
+ return { path: this.options.path, body };
75
+ }
49
76
  }
50
77
 
51
78
  $route[KIND] = RoutePrimitive;