alepha 0.20.6 → 0.20.7

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 (243) hide show
  1. package/AGENTS.md +0 -1
  2. package/CLAUDE.md +0 -1
  3. package/assets/agents-template.md +0 -1
  4. package/dist/api/audits/index.browser.js +1 -0
  5. package/dist/api/audits/index.browser.js.map +1 -1
  6. package/dist/api/audits/index.d.ts +370 -355
  7. package/dist/api/audits/index.d.ts.map +1 -1
  8. package/dist/api/audits/index.js +1 -0
  9. package/dist/api/audits/index.js.map +1 -1
  10. package/dist/api/files/index.browser.js +1 -0
  11. package/dist/api/files/index.browser.js.map +1 -1
  12. package/dist/api/files/index.d.ts +179 -170
  13. package/dist/api/files/index.d.ts.map +1 -1
  14. package/dist/api/files/index.js +1 -0
  15. package/dist/api/files/index.js.map +1 -1
  16. package/dist/api/jobs/index.browser.js +7 -0
  17. package/dist/api/jobs/index.browser.js.map +1 -1
  18. package/dist/api/jobs/index.d.ts +271 -262
  19. package/dist/api/jobs/index.d.ts.map +1 -1
  20. package/dist/api/jobs/index.js +21 -3
  21. package/dist/api/jobs/index.js.map +1 -1
  22. package/dist/api/keys/index.d.ts +198 -192
  23. package/dist/api/keys/index.d.ts.map +1 -1
  24. package/dist/api/keys/index.js +1 -0
  25. package/dist/api/keys/index.js.map +1 -1
  26. package/dist/api/notifications/index.d.ts +246 -245
  27. package/dist/api/notifications/index.d.ts.map +1 -1
  28. package/dist/api/organizations/index.d.ts +100 -97
  29. package/dist/api/organizations/index.d.ts.map +1 -1
  30. package/dist/api/parameters/index.d.ts +323 -320
  31. package/dist/api/parameters/index.d.ts.map +1 -1
  32. package/dist/api/payments/index.d.ts +431 -376
  33. package/dist/api/payments/index.d.ts.map +1 -1
  34. package/dist/api/payments/index.js +202 -87
  35. package/dist/api/payments/index.js.map +1 -1
  36. package/dist/api/subscriptions/index.d.ts +1695 -0
  37. package/dist/api/subscriptions/index.d.ts.map +1 -0
  38. package/dist/api/subscriptions/index.js +1919 -0
  39. package/dist/api/subscriptions/index.js.map +1 -0
  40. package/dist/api/users/index.d.ts +863 -847
  41. package/dist/api/users/index.d.ts.map +1 -1
  42. package/dist/api/verifications/index.d.ts +126 -125
  43. package/dist/api/verifications/index.d.ts.map +1 -1
  44. package/dist/bucket/index.d.ts +3 -2
  45. package/dist/bucket/index.d.ts.map +1 -1
  46. package/dist/cache/core/index.d.ts +114 -4
  47. package/dist/cache/core/index.d.ts.map +1 -1
  48. package/dist/cache/core/index.js +181 -15
  49. package/dist/cache/core/index.js.map +1 -1
  50. package/dist/cache/core/index.workerd.js +181 -15
  51. package/dist/cache/core/index.workerd.js.map +1 -1
  52. package/dist/cache/database/index.d.ts +20 -19
  53. package/dist/cache/database/index.d.ts.map +1 -1
  54. package/dist/cache/redis/index.d.ts +3 -2
  55. package/dist/cache/redis/index.d.ts.map +1 -1
  56. package/dist/cli/core/index.d.ts +113 -129
  57. package/dist/cli/core/index.d.ts.map +1 -1
  58. package/dist/cli/core/index.js +75 -7
  59. package/dist/cli/core/index.js.map +1 -1
  60. package/dist/cli/devtools/index.d.ts +3 -2
  61. package/dist/cli/devtools/index.d.ts.map +1 -1
  62. package/dist/cli/platform/index.d.ts +346 -290
  63. package/dist/cli/platform/index.d.ts.map +1 -1
  64. package/dist/cli/platform/index.js +105 -6
  65. package/dist/cli/platform/index.js.map +1 -1
  66. package/dist/cli/vendor/index.d.ts +12 -11
  67. package/dist/cli/vendor/index.d.ts.map +1 -1
  68. package/dist/command/index.d.ts +5 -4
  69. package/dist/command/index.d.ts.map +1 -1
  70. package/dist/core/index.browser.js +1 -1
  71. package/dist/core/index.browser.js.map +1 -1
  72. package/dist/core/index.d.ts +119 -118
  73. package/dist/core/index.d.ts.map +1 -1
  74. package/dist/core/index.js +1 -1
  75. package/dist/core/index.js.map +1 -1
  76. package/dist/core/index.native.js +1 -1
  77. package/dist/core/index.native.js.map +1 -1
  78. package/dist/core/index.workerd.js +1 -1
  79. package/dist/core/index.workerd.js.map +1 -1
  80. package/dist/crypto/index.d.ts +3 -2
  81. package/dist/crypto/index.d.ts.map +1 -1
  82. package/dist/email/core/index.d.ts +3 -2
  83. package/dist/email/core/index.d.ts.map +1 -1
  84. package/dist/email/smtp/index.d.ts +7 -6
  85. package/dist/email/smtp/index.d.ts.map +1 -1
  86. package/dist/lock/core/index.d.ts +5 -4
  87. package/dist/lock/core/index.d.ts.map +1 -1
  88. package/dist/logger/index.d.ts +10 -9
  89. package/dist/logger/index.d.ts.map +1 -1
  90. package/dist/mcp/index.d.ts +9 -8
  91. package/dist/mcp/index.d.ts.map +1 -1
  92. package/dist/mcp/index.js +1 -1
  93. package/dist/mcp/index.js.map +1 -1
  94. package/dist/orm/core/index.browser.js +9 -3
  95. package/dist/orm/core/index.browser.js.map +1 -1
  96. package/dist/orm/core/index.bun.js +31 -10
  97. package/dist/orm/core/index.bun.js.map +1 -1
  98. package/dist/orm/core/index.d.ts +33 -14
  99. package/dist/orm/core/index.d.ts.map +1 -1
  100. package/dist/orm/core/index.js +31 -10
  101. package/dist/orm/core/index.js.map +1 -1
  102. package/dist/orm/postgres/index.d.ts +6 -5
  103. package/dist/orm/postgres/index.d.ts.map +1 -1
  104. package/dist/queue/core/index.d.ts +5 -4
  105. package/dist/queue/core/index.d.ts.map +1 -1
  106. package/dist/queue/redis/index.d.ts +3 -2
  107. package/dist/queue/redis/index.d.ts.map +1 -1
  108. package/dist/react/form/index.d.ts +5 -0
  109. package/dist/react/form/index.d.ts.map +1 -1
  110. package/dist/react/form/index.js +6 -4
  111. package/dist/react/form/index.js.map +1 -1
  112. package/dist/react/i18n/index.d.ts +2 -1
  113. package/dist/react/i18n/index.d.ts.map +1 -1
  114. package/dist/react/router/index.d.ts +206 -205
  115. package/dist/react/router/index.d.ts.map +1 -1
  116. package/dist/react/ui/index.d.ts +11 -11
  117. package/dist/react/ui/index.d.ts.map +1 -1
  118. package/dist/scheduler/index.d.ts +3 -2
  119. package/dist/scheduler/index.d.ts.map +1 -1
  120. package/dist/security/index.browser.js +29 -1
  121. package/dist/security/index.browser.js.map +1 -1
  122. package/dist/security/index.d.ts +82 -35
  123. package/dist/security/index.d.ts.map +1 -1
  124. package/dist/security/index.js +56 -3
  125. package/dist/security/index.js.map +1 -1
  126. package/dist/server/auth/index.d.ts +163 -158
  127. package/dist/server/auth/index.d.ts.map +1 -1
  128. package/dist/server/auth/index.js +16 -4
  129. package/dist/server/auth/index.js.map +1 -1
  130. package/dist/server/core/index.d.ts +35 -34
  131. package/dist/server/core/index.d.ts.map +1 -1
  132. package/dist/server/cors/index.d.ts +7 -6
  133. package/dist/server/cors/index.d.ts.map +1 -1
  134. package/dist/server/health/index.d.ts +16 -15
  135. package/dist/server/health/index.d.ts.map +1 -1
  136. package/dist/server/links/index.d.ts +51 -50
  137. package/dist/server/links/index.d.ts.map +1 -1
  138. package/dist/server/rate-limit/index.d.ts +6 -5
  139. package/dist/server/rate-limit/index.d.ts.map +1 -1
  140. package/dist/server/swagger/index.d.ts +2 -1
  141. package/dist/server/swagger/index.d.ts.map +1 -1
  142. package/dist/topic/redis/index.d.ts +3 -2
  143. package/dist/topic/redis/index.d.ts.map +1 -1
  144. package/package.json +16 -32
  145. package/src/api/audits/entities/audits.ts +1 -0
  146. package/src/api/files/entities/files.ts +1 -0
  147. package/src/api/jobs/__tests__/$job.spec.ts +92 -40
  148. package/src/api/jobs/entities/jobExecutionEntity.ts +1 -0
  149. package/src/api/jobs/providers/JobProvider.ts +20 -5
  150. package/src/api/jobs/schemas/jobConfigAtom.ts +5 -0
  151. package/src/api/keys/entities/apiKeyEntity.ts +1 -0
  152. package/src/api/payments/controllers/MockCheckoutController.ts +146 -0
  153. package/src/api/payments/index.ts +3 -0
  154. package/src/api/payments/providers/MemoryPaymentProvider.ts +9 -4
  155. package/src/api/payments/providers/PaymentProvider.ts +25 -9
  156. package/src/api/payments/services/PaymentService.ts +3 -0
  157. package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
  158. package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
  159. package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
  160. package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
  161. package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
  162. package/src/api/subscriptions/entities/subscriptions.ts +68 -0
  163. package/src/api/subscriptions/index.ts +133 -0
  164. package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
  165. package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
  166. package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
  167. package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
  168. package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
  169. package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
  170. package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
  171. package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
  172. package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
  173. package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
  174. package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
  175. package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
  176. package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
  177. package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
  178. package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
  179. package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
  180. package/src/api/subscriptions/services/BillingService.ts +437 -0
  181. package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
  182. package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
  183. package/src/api/subscriptions/services/UsageService.ts +118 -0
  184. package/src/cache/core/__tests__/$cache.memory.spec.ts +450 -0
  185. package/src/cache/core/__tests__/$cache.swr.spec.ts +394 -0
  186. package/src/cache/core/index.ts +16 -0
  187. package/src/cache/core/primitives/$cache.ts +347 -21
  188. package/src/cli/core/tasks/BuildCloudflareTask.ts +16 -0
  189. package/src/cli/core/templates/agentMd.ts +39 -4
  190. package/src/cli/core/templates/biomeJson.ts +25 -1
  191. package/src/cli/core/templates/saasAdminLayoutTsx.ts +2 -2
  192. package/src/cli/platform/__tests__/CloudflareAdapter.spec.ts +117 -0
  193. package/src/cli/platform/adapters/CloudflareAdapter.ts +104 -7
  194. package/src/cli/platform/atoms/platformOptions.ts +13 -0
  195. package/src/cli/platform/schemas/platform.ts +1 -0
  196. package/src/cli/platform/services/CloudflareApi.ts +61 -0
  197. package/src/cli/platform/services/PlatformOrchestrator.ts +9 -4
  198. package/src/core/__tests__/$module.spec.ts +2 -2
  199. package/src/core/primitives/$module.ts +4 -4
  200. package/src/mcp/providers/McpServerProvider.ts +1 -1
  201. package/src/orm/core/providers/DatabaseTypeProvider.ts +9 -3
  202. package/src/orm/core/providers/drivers/DatabaseProvider.ts +1 -1
  203. package/src/orm/core/schemas/insertSchema.ts +10 -2
  204. package/src/orm/core/services/Repository.ts +27 -7
  205. package/src/react/form/hooks/useFormState.ts +8 -1
  206. package/src/react/form/index.ts +10 -1
  207. package/src/react/form/services/FormModel.ts +9 -3
  208. package/src/security/atoms/currentTenantAtom.ts +34 -0
  209. package/src/security/index.browser.ts +1 -0
  210. package/src/security/index.ts +12 -1
  211. package/src/security/primitives/$issuer.ts +17 -1
  212. package/src/security/providers/SecurityProvider.ts +37 -0
  213. package/src/server/auth/__tests__/validateRedirectUri.spec.ts +78 -0
  214. package/src/server/auth/providers/ServerAuthProvider.ts +21 -5
  215. package/tsconfig.base.json +2 -1
  216. package/dist/react/websocket/index.d.ts +0 -117
  217. package/dist/react/websocket/index.d.ts.map +0 -1
  218. package/dist/react/websocket/index.js +0 -108
  219. package/dist/react/websocket/index.js.map +0 -1
  220. package/dist/websocket/index.browser.js +0 -848
  221. package/dist/websocket/index.browser.js.map +0 -1
  222. package/dist/websocket/index.d.ts +0 -876
  223. package/dist/websocket/index.d.ts.map +0 -1
  224. package/dist/websocket/index.js +0 -1185
  225. package/dist/websocket/index.js.map +0 -1
  226. package/src/react/websocket/hooks/useRoom.tsx +0 -251
  227. package/src/react/websocket/index.ts +0 -7
  228. package/src/websocket/__tests__/$channel.spec.ts +0 -30
  229. package/src/websocket/__tests__/$websocket-new.spec.ts +0 -195
  230. package/src/websocket/__tests__/RoomManager.spec.ts +0 -146
  231. package/src/websocket/__tests__/websocket-integration.spec.ts +0 -951
  232. package/src/websocket/errors/WebSocketError.ts +0 -34
  233. package/src/websocket/index.browser.ts +0 -25
  234. package/src/websocket/index.shared.ts +0 -8
  235. package/src/websocket/index.ts +0 -85
  236. package/src/websocket/interfaces/WebSocketInterfaces.ts +0 -252
  237. package/src/websocket/primitives/$channel.ts +0 -131
  238. package/src/websocket/primitives/$websocket.ts +0 -107
  239. package/src/websocket/providers/NodeWebSocketServerProvider.ts +0 -617
  240. package/src/websocket/providers/WebSocketServerProvider.ts +0 -56
  241. package/src/websocket/services/RoomManager.ts +0 -160
  242. package/src/websocket/services/WebSocketClient.ts +0 -642
  243. package/src/websocket/services/WebSocketTopicService.ts +0 -108
@@ -0,0 +1,34 @@
1
+ import { $atom, t } from "alepha";
2
+
3
+ /**
4
+ * Atom storing the active tenant for the current request.
5
+ *
6
+ * Transport-agnostic — works with HTTP, MCP, pipelines, jobs, and any context
7
+ * that sets the atom before calling tenant-scoped logic.
8
+ *
9
+ * Typically set by an app-level middleware that resolves the tenant from the
10
+ * request `Host` header (or another signal) and writes the resolved id to the
11
+ * store. Framework code that reads this atom:
12
+ *
13
+ * - Repository scoping: `withOrganization` / `stampOrganization` prefer this
14
+ * value over `currentUserAtom.organization` so cross-tenant users (admins,
15
+ * agency operators) are scoped to the tenant they are currently acting in
16
+ * rather than the one they belong to.
17
+ * - Session creation: the value is persisted into the JWT as a `tenant` claim,
18
+ * and the issuer resolver rejects tokens whose claim does not match the
19
+ * tenant resolved from the current request.
20
+ *
21
+ * `id` is a free-form string so the framework stays neutral on tenant identity
22
+ * (slug, UUID, composite). Pick whatever matches the column marked with
23
+ * `PG_ORGANIZATION` in your entities.
24
+ */
25
+ export const currentTenantAtom = $atom({
26
+ name: "alepha.security.tenant",
27
+ schema: t.optional(
28
+ t.object({
29
+ id: t.text({
30
+ description: "Tenant identifier (slug, UUID, or composite).",
31
+ }),
32
+ }),
33
+ ),
34
+ });
@@ -2,6 +2,7 @@ import { $module } from "alepha";
2
2
 
3
3
  // ---------------------------------------------------------------------------------------------------------------------
4
4
 
5
+ export * from "./atoms/currentTenantAtom.ts";
5
6
  export * from "./atoms/currentUserAtom.ts";
6
7
  export * from "./errors/InvalidCredentialsError.ts";
7
8
  export * from "./errors/InvalidPermissionError.ts";
@@ -1,5 +1,6 @@
1
1
  import { $module } from "alepha";
2
2
  import type { FetchOptions } from "alepha/server";
3
+ import { currentTenantAtom } from "./atoms/currentTenantAtom.ts";
3
4
  import { currentUserAtom } from "./atoms/currentUserAtom.ts";
4
5
  import type { UserAccountToken } from "./interfaces/UserAccountToken.ts";
5
6
  import { $issuer } from "./primitives/$issuer.ts";
@@ -13,6 +14,7 @@ import type { UserAccount } from "./schemas/userAccountInfoSchema.ts";
13
14
  // ---------------------------------------------------------------------------------------------------------------------
14
15
 
15
16
  export * from "alepha/crypto";
17
+ export * from "./atoms/currentTenantAtom.ts";
16
18
  export * from "./atoms/currentUserAtom.ts";
17
19
  export * from "./errors/InvalidCredentialsError.ts";
18
20
  export * from "./errors/InvalidPermissionError.ts";
@@ -55,6 +57,15 @@ declare module "alepha" {
55
57
  * The current authenticated user.
56
58
  */
57
59
  "alepha.security.user"?: UserAccount;
60
+
61
+ /**
62
+ * The tenant the current request is acting in.
63
+ *
64
+ * Typically set by an app-level middleware from the request `Host`. When
65
+ * present, `Repository` scoping and session creation prefer this value
66
+ * over `currentUserAtom.organization`.
67
+ */
68
+ "alepha.security.tenant"?: { id: string };
58
69
  }
59
70
  }
60
71
 
@@ -100,6 +111,6 @@ declare module "alepha/server" {
100
111
  export const AlephaSecurity = $module({
101
112
  name: "alepha.security",
102
113
  primitives: [$issuer, $role, $permission],
103
- atoms: [currentUserAtom],
114
+ atoms: [currentUserAtom, currentTenantAtom],
104
115
  services: [SecurityProvider, JwtProvider, ServerSecurityProvider],
105
116
  });
@@ -1,4 +1,11 @@
1
- import { $inject, AlephaError, createPrimitive, KIND, Primitive } from "alepha";
1
+ import {
2
+ $inject,
3
+ Alepha,
4
+ AlephaError,
5
+ createPrimitive,
6
+ KIND,
7
+ Primitive,
8
+ } from "alepha";
2
9
  import {
3
10
  DateTimeProvider,
4
11
  type Duration,
@@ -7,6 +14,7 @@ import {
7
14
  import { $logger } from "alepha/logger";
8
15
  import type { ServerRequest } from "alepha/server";
9
16
  import type { JSONWebKeySet, JWTPayload } from "jose";
17
+ import { currentTenantAtom } from "../atoms/currentTenantAtom.ts";
10
18
  import { SecurityError } from "../errors/SecurityError.ts";
11
19
  import type { IssuerResolver } from "../interfaces/IssuerResolver.ts";
12
20
  import { JwtProvider } from "../providers/JwtProvider.ts";
@@ -119,6 +127,7 @@ export interface IssuerExternal {
119
127
  // ---------------------------------------------------------------------------------------------------------------------
120
128
 
121
129
  export class IssuerPrimitive extends Primitive<IssuerPrimitiveOptions> {
130
+ protected readonly alepha = $inject(Alepha);
122
131
  protected readonly securityProvider = $inject(SecurityProvider);
123
132
  protected readonly dateTimeProvider = $inject(DateTimeProvider);
124
133
  protected readonly jwt = $inject(JwtProvider);
@@ -302,6 +311,12 @@ export class IssuerPrimitive extends Primitive<IssuerPrimitiveOptions> {
302
311
  aud: this.name,
303
312
  });
304
313
 
314
+ // Bind the token to the tenant the request is acting in. The default JWT
315
+ // resolver compares this claim against `currentTenantAtom` on every
316
+ // request, so a token minted on `b14.club.alepha.dev` is rejected on
317
+ // `viska.club.alepha.dev` even when the user belongs to both.
318
+ const tenant = this.alepha.store.get(currentTenantAtom)?.id;
319
+
305
320
  const access_token = await this.jwt.create(
306
321
  {
307
322
  // jwt
@@ -318,6 +333,7 @@ export class IssuerPrimitive extends Primitive<IssuerPrimitiveOptions> {
318
333
  // our claims
319
334
  organization: user.organization,
320
335
  roles: user.roles,
336
+ tenant,
321
337
  },
322
338
  this.name,
323
339
  );
@@ -10,6 +10,7 @@ import { $logger } from "alepha/logger";
10
10
  import { ForbiddenError } from "alepha/server";
11
11
  import type { JSONWebKeySet, JWTPayload } from "jose";
12
12
  import type { JWTVerifyOptions } from "jose/jwt/verify";
13
+ import { currentTenantAtom } from "../atoms/currentTenantAtom.ts";
13
14
  import { currentUserAtom } from "../atoms/currentUserAtom.ts";
14
15
  import { InvalidPermissionError } from "../errors/InvalidPermissionError.ts";
15
16
  import { InvalidTokenError } from "../errors/InvalidTokenError.ts";
@@ -110,6 +111,23 @@ export class SecurityProvider {
110
111
  // Parse and validate JWT
111
112
  const { result } = await this.jwt.parse(token, realmName);
112
113
 
114
+ // Reject tokens whose tenant claim doesn't match the tenant resolved
115
+ // for the current request. Prevents a token minted on tenant A from
116
+ // being replayed on tenant B (subdomain spoofing, leaked bearer
117
+ // tokens). Tokens minted without a tenant claim (no active tenant at
118
+ // session creation) pass through — single-tenant apps are unaffected.
119
+ const claimTenant = this.getTenantFromPayload(result.payload);
120
+ if (claimTenant) {
121
+ const activeTenant = this.alepha.store.get(currentTenantAtom)?.id;
122
+ if (activeTenant && activeTenant !== claimTenant) {
123
+ this.log.warn("JWT tenant claim does not match active tenant", {
124
+ claim: claimTenant,
125
+ active: activeTenant,
126
+ });
127
+ return null;
128
+ }
129
+ }
130
+
113
131
  // Extract user info from JWT payload
114
132
  return this.createUserFromPayload(result.payload, realmName);
115
133
  },
@@ -927,6 +945,25 @@ export class SecurityProvider {
927
945
  return payload.organization;
928
946
  }
929
947
  }
948
+
949
+ /**
950
+ * Extracts the tenant id from the JWT payload, when present.
951
+ *
952
+ * Tokens minted with no active tenant (single-tenant apps, server-to-server
953
+ * calls before any request-scoped middleware runs) omit the claim, in which
954
+ * case the resolver does not enforce a tenant match.
955
+ */
956
+ public getTenantFromPayload(
957
+ payload: Record<string, any>,
958
+ ): string | undefined {
959
+ if (!payload) {
960
+ return;
961
+ }
962
+
963
+ if (typeof payload.tenant === "string") {
964
+ return payload.tenant;
965
+ }
966
+ }
930
967
  }
931
968
 
932
969
  // =====================================================================================================================
@@ -0,0 +1,78 @@
1
+ import { Alepha } from "alepha";
2
+ import { describe, it } from "vitest";
3
+ import { AlephaServerAuth } from "../index.ts";
4
+ import { ServerAuthProvider } from "../providers/ServerAuthProvider.ts";
5
+
6
+ class ProviderProbe extends ServerAuthProvider {
7
+ public probeValidate(uri: string) {
8
+ return this.validateRedirectUri(uri);
9
+ }
10
+ }
11
+
12
+ const setup = async (env: Record<string, string> = {}) => {
13
+ const alepha = Alepha.create({ env });
14
+ alepha.with(AlephaServerAuth);
15
+ alepha.with(ProviderProbe);
16
+ await alepha.start();
17
+ return alepha.inject(ProviderProbe);
18
+ };
19
+
20
+ describe("validateRedirectUri", () => {
21
+ describe("without COOKIE_PARENT_DOMAIN (legacy behaviour)", () => {
22
+ it("accepts relative paths", async ({ expect }) => {
23
+ const p = await setup({});
24
+ expect(p.probeValidate("/admin")).toBe("/admin");
25
+ });
26
+ it("rejects protocol-relative URLs", async ({ expect }) => {
27
+ const p = await setup({});
28
+ expect(p.probeValidate("//evil.com")).toBe("/");
29
+ });
30
+ it("rejects absolute URLs", async ({ expect }) => {
31
+ const p = await setup({});
32
+ expect(p.probeValidate("https://evil.com/x")).toBe("/");
33
+ });
34
+ });
35
+
36
+ describe("with COOKIE_PARENT_DOMAIN=.club.alepha.dev", () => {
37
+ const env = { COOKIE_PARENT_DOMAIN: ".club.alepha.dev" };
38
+ it("still accepts relative paths", async ({ expect }) => {
39
+ const p = await setup(env);
40
+ expect(p.probeValidate("/admin")).toBe("/admin");
41
+ });
42
+ it("still rejects protocol-relative URLs", async ({ expect }) => {
43
+ const p = await setup(env);
44
+ expect(p.probeValidate("//evil.com")).toBe("/");
45
+ });
46
+ it("rejects unrelated absolute URLs", async ({ expect }) => {
47
+ const p = await setup(env);
48
+ expect(p.probeValidate("https://evil.com/x")).toBe("/");
49
+ });
50
+ it("rejects http (must be https)", async ({ expect }) => {
51
+ const p = await setup(env);
52
+ expect(p.probeValidate("http://viscapadel.club.alepha.dev/admin")).toBe(
53
+ "/",
54
+ );
55
+ });
56
+ it("accepts subdomain under the parent domain", async ({ expect }) => {
57
+ const p = await setup(env);
58
+ expect(p.probeValidate("https://viscapadel.club.alepha.dev/admin")).toBe(
59
+ "https://viscapadel.club.alepha.dev/admin",
60
+ );
61
+ });
62
+ it("accepts the bare parent domain", async ({ expect }) => {
63
+ const p = await setup(env);
64
+ expect(p.probeValidate("https://club.alepha.dev/dashboard")).toBe(
65
+ "https://club.alepha.dev/dashboard",
66
+ );
67
+ });
68
+ it("rejects look-alike domains (suffix match without dot boundary)", async ({
69
+ expect,
70
+ }) => {
71
+ const p = await setup(env);
72
+ expect(p.probeValidate("https://notclub.alepha.dev/x")).toBe("/");
73
+ expect(
74
+ p.probeValidate("https://evil-club.alepha.dev.attacker.com/x"),
75
+ ).toBe("/");
76
+ });
77
+ });
78
+ });
@@ -41,14 +41,30 @@ export class ServerAuthProvider {
41
41
  protected readonly serverLinksProvider = $inject(ServerLinksProvider);
42
42
 
43
43
  /**
44
- * Validates that a redirect URI is a safe relative path.
45
- * Prevents open redirect attacks by rejecting absolute URLs and protocol-relative URLs.
44
+ * Validates that a redirect URI is a safe relative path, or — when
45
+ * COOKIE_PARENT_DOMAIN is configured an https URL whose host is the
46
+ * parent domain or a subdomain of it. Used by SaaS deployments where the
47
+ * OAuth callback dispatches users back to their tenant subdomain.
48
+ *
49
+ * Prevents open redirect attacks by rejecting any other absolute URL.
46
50
  */
47
51
  protected validateRedirectUri(uri: string): string {
48
- if (!uri.startsWith("/") || uri.startsWith("//")) {
49
- return "/";
52
+ if (uri.startsWith("/") && !uri.startsWith("//")) {
53
+ return uri;
50
54
  }
51
- return uri;
55
+ const parent = this.alepha.env.COOKIE_PARENT_DOMAIN;
56
+ if (typeof parent === "string" && parent) {
57
+ try {
58
+ const parsed = new URL(uri);
59
+ const parentHost = parent.startsWith(".") ? parent.slice(1) : parent;
60
+ if (parsed.protocol !== "https:") return "/";
61
+ if (parsed.host === parentHost) return uri;
62
+ if (parsed.host.endsWith(`.${parentHost}`)) return uri;
63
+ } catch {
64
+ // fall through
65
+ }
66
+ }
67
+ return "/";
52
68
  }
53
69
 
54
70
  public get identities(): Array<AuthPrimitive> {
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "module": "nodenext",
3
+ "module": "esnext",
4
+ "moduleResolution": "bundler",
4
5
  "target": "esnext",
5
6
  "strict": true,
6
7
  "jsx": "react-jsx",
@@ -1,117 +0,0 @@
1
- import { ChannelPrimitive, TWSObject } from "alepha/websocket";
2
- import { Static } from "alepha";
3
-
4
- //#region ../../src/react/websocket/hooks/useRoom.d.ts
5
- /**
6
- * UseRoom options
7
- */
8
- interface UseRoomOptions<TClient extends TWSObject, TServer extends TWSObject> {
9
- /**
10
- * Room ID to connect to
11
- */
12
- roomId: string;
13
- /**
14
- * Channel primitive defining the schemas
15
- */
16
- channel: ChannelPrimitive<TClient, TServer>;
17
- /**
18
- * Handler for incoming messages from the server
19
- */
20
- handler: (message: Static<TClient>) => void;
21
- /**
22
- * Optional WebSocket URL override
23
- * Defaults to auto-detected URL based on window.location
24
- */
25
- url?: string;
26
- /**
27
- * Enable automatic reconnection on disconnect
28
- * @default true
29
- */
30
- autoReconnect?: boolean;
31
- /**
32
- * Reconnection interval in milliseconds
33
- * @default 3000
34
- */
35
- reconnectInterval?: number;
36
- /**
37
- * Maximum reconnection attempts (-1 for infinite)
38
- * @default 10
39
- */
40
- maxReconnectAttempts?: number;
41
- /**
42
- * Called when connection is established
43
- */
44
- onConnect?: () => void;
45
- /**
46
- * Called when connection is closed
47
- */
48
- onDisconnect?: () => void;
49
- /**
50
- * Called on connection error
51
- */
52
- onError?: (error: Error) => void;
53
- }
54
- /**
55
- * UseRoom return value
56
- */
57
- interface UseRoomReturn<TServer extends TWSObject> {
58
- /**
59
- * Send a message to the server
60
- */
61
- send: (message: Static<TServer>) => Promise<void>;
62
- /**
63
- * Whether the connection is established
64
- */
65
- isConnected: boolean;
66
- /**
67
- * Whether the connection is in progress
68
- */
69
- isConnecting: boolean;
70
- /**
71
- * Whether there was an error
72
- */
73
- isError: boolean;
74
- /**
75
- * The error object if any
76
- */
77
- error?: Error;
78
- /**
79
- * Manually reconnect
80
- */
81
- reconnect: () => void;
82
- /**
83
- * Manually disconnect
84
- */
85
- disconnect: () => void;
86
- }
87
- /**
88
- * React hook for WebSocket room communication
89
- *
90
- * Provides automatic connection management, reconnection, and type-safe messaging
91
- * for WebSocket rooms using the injected WebSocketClient service.
92
- *
93
- * Multiple useRoom hooks on the same channel will share a single WebSocket connection.
94
- *
95
- * @example
96
- * ```tsx
97
- * const chat = useRoom({
98
- * roomId: "room-123",
99
- * channel: chatChannel,
100
- * handler: (message) => {
101
- * if (message.type === "append") {
102
- * setMessages(prev => [...prev, message]);
103
- * }
104
- * }
105
- * }, [roomId]);
106
- *
107
- * const sendMessage = async () => {
108
- * await chat.send({
109
- * content: "Hello, world!"
110
- * });
111
- * };
112
- * ```
113
- */
114
- declare const useRoom: <TClient extends TWSObject, TServer extends TWSObject>(options: UseRoomOptions<TClient, TServer>, deps: unknown[]) => UseRoomReturn<TServer>;
115
- //#endregion
116
- export { UseRoomOptions, UseRoomReturn, useRoom };
117
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../../src/react/websocket/hooks/useRoom.tsx"],"mappings":";;;;;;AASA;UAAiB,cAAA,iBACC,SAAA,kBACA,SAAA;EAFa;;;EAO7B,MAAA;EAKmC;;;EAAnC,OAAA,EAAS,gBAAA,CAAiB,OAAA,EAAS,OAAA;EA4CjB;;;EAvClB,OAAA,GAAU,OAAA,EAAS,MAAA,CAAO,OAAA;EAhBV;;;;EAsBhB,GAAA;EAXS;;;;EAiBT,aAAA;EAZ0B;;;;EAkB1B,iBAAA;EAMA;;;;EAAA,oBAAA;EAeW;;;EAVX,SAAA;EAgB4B;;;EAX5B,YAAA;EAegB;;;EAVhB,OAAA,IAAW,KAAA,EAAO,KAAA;AAAA;;;;UAMH,aAAA,iBAA8B,SAAA;EAItB;;;EAAvB,IAAA,GAAO,OAAA,EAAS,MAAA,CAAO,OAAA,MAAa,OAAA;EAUpC;;;EALA,WAAA;EAoBA;;;EAfA,YAAA;EAkDW;;;EA7CX,OAAA;EA6CiE;;;EAxCjE,KAAA,GAAQ,KAAA;EA2CO;;;EAtCf,SAAA;EAmCsB;;;EA9BtB,UAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA8BW,OAAA,mBAA2B,SAAA,kBAA2B,SAAA,EACjE,OAAA,EAAS,cAAA,CAAe,OAAA,EAAS,OAAA,GACjC,IAAA,gBACC,aAAA,CAAc,OAAA"}
@@ -1,108 +0,0 @@
1
- import { useAlepha, useInject } from "alepha/react";
2
- import { WebSocketClient } from "alepha/websocket";
3
- import { useEffect, useRef, useState } from "react";
4
- //#region ../../src/react/websocket/hooks/useRoom.tsx
5
- /**
6
- * React hook for WebSocket room communication
7
- *
8
- * Provides automatic connection management, reconnection, and type-safe messaging
9
- * for WebSocket rooms using the injected WebSocketClient service.
10
- *
11
- * Multiple useRoom hooks on the same channel will share a single WebSocket connection.
12
- *
13
- * @example
14
- * ```tsx
15
- * const chat = useRoom({
16
- * roomId: "room-123",
17
- * channel: chatChannel,
18
- * handler: (message) => {
19
- * if (message.type === "append") {
20
- * setMessages(prev => [...prev, message]);
21
- * }
22
- * }
23
- * }, [roomId]);
24
- *
25
- * const sendMessage = async () => {
26
- * await chat.send({
27
- * content: "Hello, world!"
28
- * });
29
- * };
30
- * ```
31
- */
32
- const useRoom = (options, deps) => {
33
- const webSocketClient = useInject(WebSocketClient);
34
- const unsubscribeRef = useRef(null);
35
- const [isConnected, setIsConnected] = useState(false);
36
- const [isConnecting, setIsConnecting] = useState(false);
37
- const [isError, setIsError] = useState(false);
38
- const [error, setError] = useState(void 0);
39
- const { roomId, channel, handler, url, autoReconnect, reconnectInterval, maxReconnectAttempts, onConnect, onDisconnect, onError } = options;
40
- const handlerRef = useRef(handler);
41
- handlerRef.current = handler;
42
- useEffect(() => {
43
- const unsubscribe = webSocketClient.subscribe(roomId, channel, (msg) => handlerRef.current(msg), {
44
- url,
45
- autoReconnect,
46
- reconnectInterval,
47
- maxReconnectAttempts,
48
- onConnect: () => {
49
- setIsConnected(true);
50
- setIsConnecting(false);
51
- setIsError(false);
52
- setError(void 0);
53
- onConnect?.();
54
- },
55
- onDisconnect: () => {
56
- setIsConnected(false);
57
- setIsConnecting(false);
58
- onDisconnect?.();
59
- },
60
- onError: (err) => {
61
- setIsError(true);
62
- setError(err);
63
- setIsConnecting(false);
64
- onError?.(err);
65
- }
66
- });
67
- unsubscribeRef.current = unsubscribe;
68
- const connection = webSocketClient.getConnection(channel);
69
- if (connection) {
70
- setIsConnected(connection.isConnected);
71
- setIsConnecting(connection.isConnecting);
72
- setIsError(connection.isError);
73
- setError(connection.error);
74
- }
75
- return () => {
76
- unsubscribe();
77
- unsubscribeRef.current = null;
78
- };
79
- }, deps);
80
- if (!useAlepha().isBrowser()) return {
81
- send: async (_message) => {},
82
- isConnected: false,
83
- isConnecting: false,
84
- isError: false,
85
- error: void 0,
86
- reconnect: () => {},
87
- disconnect: () => {}
88
- };
89
- return {
90
- send: async (message) => {
91
- await webSocketClient.send(roomId, channel, message);
92
- },
93
- isConnected,
94
- isConnecting,
95
- isError,
96
- error,
97
- reconnect: () => {
98
- webSocketClient.getConnection(channel)?.reconnect();
99
- },
100
- disconnect: () => {
101
- unsubscribeRef.current?.();
102
- }
103
- };
104
- };
105
- //#endregion
106
- export { useRoom };
107
-
108
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/react/websocket/hooks/useRoom.tsx"],"sourcesContent":["import type { Static } from \"alepha\";\nimport { useAlepha, useInject } from \"alepha/react\";\nimport type { ChannelPrimitive, TWSObject } from \"alepha/websocket\";\nimport { WebSocketClient } from \"alepha/websocket\";\nimport { useEffect, useRef, useState } from \"react\";\n\n/**\n * UseRoom options\n */\nexport interface UseRoomOptions<\n TClient extends TWSObject,\n TServer extends TWSObject,\n> {\n /**\n * Room ID to connect to\n */\n roomId: string;\n\n /**\n * Channel primitive defining the schemas\n */\n channel: ChannelPrimitive<TClient, TServer>;\n\n /**\n * Handler for incoming messages from the server\n */\n handler: (message: Static<TClient>) => void;\n\n /**\n * Optional WebSocket URL override\n * Defaults to auto-detected URL based on window.location\n */\n url?: string;\n\n /**\n * Enable automatic reconnection on disconnect\n * @default true\n */\n autoReconnect?: boolean;\n\n /**\n * Reconnection interval in milliseconds\n * @default 3000\n */\n reconnectInterval?: number;\n\n /**\n * Maximum reconnection attempts (-1 for infinite)\n * @default 10\n */\n maxReconnectAttempts?: number;\n\n /**\n * Called when connection is established\n */\n onConnect?: () => void;\n\n /**\n * Called when connection is closed\n */\n onDisconnect?: () => void;\n\n /**\n * Called on connection error\n */\n onError?: (error: Error) => void;\n}\n\n/**\n * UseRoom return value\n */\nexport interface UseRoomReturn<TServer extends TWSObject> {\n /**\n * Send a message to the server\n */\n send: (message: Static<TServer>) => Promise<void>;\n\n /**\n * Whether the connection is established\n */\n isConnected: boolean;\n\n /**\n * Whether the connection is in progress\n */\n isConnecting: boolean;\n\n /**\n * Whether there was an error\n */\n isError: boolean;\n\n /**\n * The error object if any\n */\n error?: Error;\n\n /**\n * Manually reconnect\n */\n reconnect: () => void;\n\n /**\n * Manually disconnect\n */\n disconnect: () => void;\n}\n\n/**\n * React hook for WebSocket room communication\n *\n * Provides automatic connection management, reconnection, and type-safe messaging\n * for WebSocket rooms using the injected WebSocketClient service.\n *\n * Multiple useRoom hooks on the same channel will share a single WebSocket connection.\n *\n * @example\n * ```tsx\n * const chat = useRoom({\n * roomId: \"room-123\",\n * channel: chatChannel,\n * handler: (message) => {\n * if (message.type === \"append\") {\n * setMessages(prev => [...prev, message]);\n * }\n * }\n * }, [roomId]);\n *\n * const sendMessage = async () => {\n * await chat.send({\n * content: \"Hello, world!\"\n * });\n * };\n * ```\n */\nexport const useRoom = <TClient extends TWSObject, TServer extends TWSObject>(\n options: UseRoomOptions<TClient, TServer>,\n deps: unknown[],\n): UseRoomReturn<TServer> => {\n const webSocketClient = useInject(WebSocketClient);\n const unsubscribeRef = useRef<(() => void) | null>(null);\n\n const [isConnected, setIsConnected] = useState(false);\n const [isConnecting, setIsConnecting] = useState(false);\n const [isError, setIsError] = useState(false);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n const {\n roomId,\n channel,\n handler,\n url,\n autoReconnect,\n reconnectInterval,\n maxReconnectAttempts,\n onConnect,\n onDisconnect,\n onError,\n } = options;\n\n // Keep handler ref stable to avoid stale closures\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n\n useEffect(() => {\n // Subscribe to room — use ref so handler is always current\n const unsubscribe = webSocketClient.subscribe(\n roomId,\n channel,\n (msg) => handlerRef.current(msg),\n {\n url,\n autoReconnect,\n reconnectInterval,\n maxReconnectAttempts,\n onConnect: () => {\n setIsConnected(true);\n setIsConnecting(false);\n setIsError(false);\n setError(undefined);\n onConnect?.();\n },\n onDisconnect: () => {\n setIsConnected(false);\n setIsConnecting(false);\n onDisconnect?.();\n },\n onError: (err) => {\n setIsError(true);\n setError(err);\n setIsConnecting(false);\n onError?.(err);\n },\n },\n );\n\n unsubscribeRef.current = unsubscribe;\n\n // Get initial state from connection\n const connection = webSocketClient.getConnection(channel);\n if (connection) {\n setIsConnected(connection.isConnected);\n setIsConnecting(connection.isConnecting);\n setIsError(connection.isError);\n setError(connection.error);\n }\n\n // Cleanup on unmount or deps change\n return () => {\n unsubscribe();\n unsubscribeRef.current = null;\n };\n }, deps);\n\n const alepha = useAlepha();\n\n if (!alepha.isBrowser()) {\n return {\n send: async (_message: Static<TServer>) => {\n // No-op on server\n },\n isConnected: false,\n isConnecting: false,\n isError: false,\n error: undefined,\n reconnect: () => {\n // No-op on server\n },\n disconnect: () => {\n // No-op on server\n },\n };\n }\n\n return {\n send: async (message: Static<TServer>) => {\n await webSocketClient.send(roomId, channel, message);\n },\n isConnected,\n isConnecting,\n isError,\n error,\n reconnect: () => {\n const connection = webSocketClient.getConnection(channel);\n connection?.reconnect();\n },\n disconnect: () => {\n unsubscribeRef.current?.();\n },\n };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuIA,MAAa,WACX,SACA,SAC2B;CAC3B,MAAM,kBAAkB,UAAU,gBAAgB;CAClD,MAAM,iBAAiB,OAA4B,KAAK;CAExD,MAAM,CAAC,aAAa,kBAAkB,SAAS,MAAM;CACrD,MAAM,CAAC,cAAc,mBAAmB,SAAS,MAAM;CACvD,MAAM,CAAC,SAAS,cAAc,SAAS,MAAM;CAC7C,MAAM,CAAC,OAAO,YAAY,SAA4B,KAAA,EAAU;CAEhE,MAAM,EACJ,QACA,SACA,SACA,KACA,eACA,mBACA,sBACA,WACA,cACA,YACE;CAGJ,MAAM,aAAa,OAAO,QAAQ;CAClC,WAAW,UAAU;CAErB,gBAAgB;EAEd,MAAM,cAAc,gBAAgB,UAClC,QACA,UACC,QAAQ,WAAW,QAAQ,IAAI,EAChC;GACE;GACA;GACA;GACA;GACA,iBAAiB;IACf,eAAe,KAAK;IACpB,gBAAgB,MAAM;IACtB,WAAW,MAAM;IACjB,SAAS,KAAA,EAAU;IACnB,aAAa;;GAEf,oBAAoB;IAClB,eAAe,MAAM;IACrB,gBAAgB,MAAM;IACtB,gBAAgB;;GAElB,UAAU,QAAQ;IAChB,WAAW,KAAK;IAChB,SAAS,IAAI;IACb,gBAAgB,MAAM;IACtB,UAAU,IAAI;;GAEjB,CACF;EAED,eAAe,UAAU;EAGzB,MAAM,aAAa,gBAAgB,cAAc,QAAQ;EACzD,IAAI,YAAY;GACd,eAAe,WAAW,YAAY;GACtC,gBAAgB,WAAW,aAAa;GACxC,WAAW,WAAW,QAAQ;GAC9B,SAAS,WAAW,MAAM;;EAI5B,aAAa;GACX,aAAa;GACb,eAAe,UAAU;;IAE1B,KAAK;CAIR,IAAI,CAFW,WAEJ,CAAC,WAAW,EACrB,OAAO;EACL,MAAM,OAAO,aAA8B;EAG3C,aAAa;EACb,cAAc;EACd,SAAS;EACT,OAAO,KAAA;EACP,iBAAiB;EAGjB,kBAAkB;EAGnB;CAGH,OAAO;EACL,MAAM,OAAO,YAA6B;GACxC,MAAM,gBAAgB,KAAK,QAAQ,SAAS,QAAQ;;EAEtD;EACA;EACA;EACA;EACA,iBAAiB;GAEf,gBADmC,cAAc,QACvC,EAAE,WAAW;;EAEzB,kBAAkB;GAChB,eAAe,WAAW;;EAE7B"}