@shivaedev/identity 0.0.0 → 0.1.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 (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +516 -1
  3. package/dist/identity.d.ts +36 -0
  4. package/dist/identity.d.ts.map +1 -0
  5. package/dist/identity.js +12 -0
  6. package/dist/identity.js.map +1 -0
  7. package/dist/middleware.d.ts +21 -0
  8. package/dist/middleware.d.ts.map +1 -0
  9. package/dist/middleware.js +20 -0
  10. package/dist/middleware.js.map +1 -0
  11. package/dist/permission.d.ts +11 -0
  12. package/dist/permission.d.ts.map +1 -0
  13. package/dist/permission.js +17 -0
  14. package/dist/permission.js.map +1 -0
  15. package/dist/server/adapters/betterAuthSessions.d.ts +14 -0
  16. package/dist/server/adapters/betterAuthSessions.d.ts.map +1 -0
  17. package/dist/server/adapters/betterAuthSessions.js +24 -0
  18. package/dist/server/adapters/betterAuthSessions.js.map +1 -0
  19. package/dist/server/better-auth/allowedAccounts.d.ts +10 -0
  20. package/dist/server/better-auth/allowedAccounts.d.ts.map +1 -0
  21. package/dist/server/better-auth/allowedAccounts.js +20 -0
  22. package/dist/server/better-auth/allowedAccounts.js.map +1 -0
  23. package/dist/server/better-auth/bearerSessions.d.ts +9 -0
  24. package/dist/server/better-auth/bearerSessions.d.ts.map +1 -0
  25. package/dist/server/better-auth/bearerSessions.js +38 -0
  26. package/dist/server/better-auth/bearerSessions.js.map +1 -0
  27. package/dist/server/better-auth/memoryRateLimit.d.ts +3 -0
  28. package/dist/server/better-auth/memoryRateLimit.d.ts.map +1 -0
  29. package/dist/server/better-auth/memoryRateLimit.js +46 -0
  30. package/dist/server/better-auth/memoryRateLimit.js.map +1 -0
  31. package/dist/server/better-auth/mount.d.ts +265 -0
  32. package/dist/server/better-auth/mount.d.ts.map +1 -0
  33. package/dist/server/better-auth/mount.js +111 -0
  34. package/dist/server/better-auth/mount.js.map +1 -0
  35. package/dist/server/better-auth/mountRoutes.d.ts +22 -0
  36. package/dist/server/better-auth/mountRoutes.d.ts.map +1 -0
  37. package/dist/server/better-auth/mountRoutes.js +54 -0
  38. package/dist/server/better-auth/mountRoutes.js.map +1 -0
  39. package/dist/server/better-auth/mountedSessions.d.ts +15 -0
  40. package/dist/server/better-auth/mountedSessions.d.ts.map +1 -0
  41. package/dist/server/better-auth/mountedSessions.js +12 -0
  42. package/dist/server/better-auth/mountedSessions.js.map +1 -0
  43. package/dist/server/better-auth/revokeUnadmitted.d.ts +10 -0
  44. package/dist/server/better-auth/revokeUnadmitted.d.ts.map +1 -0
  45. package/dist/server/better-auth/revokeUnadmitted.js +29 -0
  46. package/dist/server/better-auth/revokeUnadmitted.js.map +1 -0
  47. package/dist/server/better-auth/sessionPermissions.d.ts +3 -0
  48. package/dist/server/better-auth/sessionPermissions.d.ts.map +1 -0
  49. package/dist/server/better-auth/sessionPermissions.js +16 -0
  50. package/dist/server/better-auth/sessionPermissions.js.map +1 -0
  51. package/dist/server/better-auth/signInGate.d.ts +37 -0
  52. package/dist/server/better-auth/signInGate.d.ts.map +1 -0
  53. package/dist/server/better-auth/signInGate.js +38 -0
  54. package/dist/server/better-auth/signInGate.js.map +1 -0
  55. package/dist/server/better-auth/signInPolicy.d.ts +193 -0
  56. package/dist/server/better-auth/signInPolicy.d.ts.map +1 -0
  57. package/dist/server/better-auth/signInPolicy.js +61 -0
  58. package/dist/server/better-auth/signInPolicy.js.map +1 -0
  59. package/dist/server/better-auth/testDatabase.d.ts +19 -0
  60. package/dist/server/better-auth/testDatabase.d.ts.map +1 -0
  61. package/dist/server/better-auth/testDatabase.js +24 -0
  62. package/dist/server/better-auth/testDatabase.js.map +1 -0
  63. package/dist/server/better-auth/testing.d.ts +24 -0
  64. package/dist/server/better-auth/testing.d.ts.map +1 -0
  65. package/dist/server/better-auth/testing.js +21 -0
  66. package/dist/server/better-auth/testing.js.map +1 -0
  67. package/dist/server/better-auth/userProfiles.d.ts +14 -0
  68. package/dist/server/better-auth/userProfiles.d.ts.map +1 -0
  69. package/dist/server/better-auth/userProfiles.js +20 -0
  70. package/dist/server/better-auth/userProfiles.js.map +1 -0
  71. package/dist/server/commandChanges.d.ts +5 -0
  72. package/dist/server/commandChanges.d.ts.map +1 -0
  73. package/dist/server/commandChanges.js +44 -0
  74. package/dist/server/commandChanges.js.map +1 -0
  75. package/dist/server/local.d.ts +10 -0
  76. package/dist/server/local.d.ts.map +1 -0
  77. package/dist/server/local.js +93 -0
  78. package/dist/server/local.js.map +1 -0
  79. package/dist/server/origin.d.ts +15 -0
  80. package/dist/server/origin.d.ts.map +1 -0
  81. package/dist/server/origin.js +16 -0
  82. package/dist/server/origin.js.map +1 -0
  83. package/dist/server/permissions.d.ts +9 -0
  84. package/dist/server/permissions.d.ts.map +1 -0
  85. package/dist/server/permissions.js +31 -0
  86. package/dist/server/permissions.js.map +1 -0
  87. package/dist/server/proxyRanges.d.ts +4 -0
  88. package/dist/server/proxyRanges.d.ts.map +1 -0
  89. package/dist/server/proxyRanges.js +62 -0
  90. package/dist/server/proxyRanges.js.map +1 -0
  91. package/dist/server/resolveRole.d.ts +11 -0
  92. package/dist/server/resolveRole.d.ts.map +1 -0
  93. package/dist/server/resolveRole.js +6 -0
  94. package/dist/server/resolveRole.js.map +1 -0
  95. package/dist/server/revalidateStream.d.ts +6 -0
  96. package/dist/server/revalidateStream.d.ts.map +1 -0
  97. package/dist/server/revalidateStream.js +19 -0
  98. package/dist/server/revalidateStream.js.map +1 -0
  99. package/dist/server/session.d.ts +39 -0
  100. package/dist/server/session.d.ts.map +1 -0
  101. package/dist/server/session.js +73 -0
  102. package/dist/server/session.js.map +1 -0
  103. package/dist/server/sessionKeys.d.ts +10 -0
  104. package/dist/server/sessionKeys.d.ts.map +1 -0
  105. package/dist/server/sessionKeys.js +22 -0
  106. package/dist/server/sessionKeys.js.map +1 -0
  107. package/dist/server/sessionProfile.d.ts +3 -0
  108. package/dist/server/sessionProfile.d.ts.map +1 -0
  109. package/dist/server/sessionProfile.js +4 -0
  110. package/dist/server/sessionProfile.js.map +1 -0
  111. package/dist/server/tracing.d.ts +9 -0
  112. package/dist/server/tracing.d.ts.map +1 -0
  113. package/dist/server/tracing.js +51 -0
  114. package/dist/server/tracing.js.map +1 -0
  115. package/dist/server/transportHeaders.d.ts +4 -0
  116. package/dist/server/transportHeaders.d.ts.map +1 -0
  117. package/dist/server/transportHeaders.js +6 -0
  118. package/dist/server/transportHeaders.js.map +1 -0
  119. package/dist/server/trustedProxy.d.ts +7 -0
  120. package/dist/server/trustedProxy.d.ts.map +1 -0
  121. package/dist/server/trustedProxy.js +19 -0
  122. package/dist/server/trustedProxy.js.map +1 -0
  123. package/dist/server/trustedProxyRanges.d.ts +2 -0
  124. package/dist/server/trustedProxyRanges.d.ts.map +1 -0
  125. package/dist/server/trustedProxyRanges.js +5 -0
  126. package/dist/server/trustedProxyRanges.js.map +1 -0
  127. package/package.json +86 -4
  128. package/src/identity.ts +26 -0
  129. package/src/middleware.ts +38 -0
  130. package/src/permission.ts +24 -0
  131. package/src/server/adapters/betterAuthSessions.ts +44 -0
  132. package/src/server/better-auth/allowedAccounts.ts +29 -0
  133. package/src/server/better-auth/bearerSessions.ts +50 -0
  134. package/src/server/better-auth/memoryRateLimit.ts +56 -0
  135. package/src/server/better-auth/mount.ts +145 -0
  136. package/src/server/better-auth/mountRoutes.ts +75 -0
  137. package/src/server/better-auth/mountedSessions.ts +21 -0
  138. package/src/server/better-auth/revokeUnadmitted.ts +41 -0
  139. package/src/server/better-auth/sessionPermissions.ts +18 -0
  140. package/src/server/better-auth/signInGate.ts +77 -0
  141. package/src/server/better-auth/signInPolicy.ts +72 -0
  142. package/src/server/better-auth/testDatabase.ts +27 -0
  143. package/src/server/better-auth/testing.ts +35 -0
  144. package/src/server/better-auth/userProfiles.ts +37 -0
  145. package/src/server/commandChanges.ts +52 -0
  146. package/src/server/local.ts +100 -0
  147. package/src/server/origin.ts +26 -0
  148. package/src/server/permissions.ts +51 -0
  149. package/src/server/proxyRanges.ts +61 -0
  150. package/src/server/resolveRole.ts +7 -0
  151. package/src/server/revalidateStream.ts +25 -0
  152. package/src/server/session.ts +148 -0
  153. package/src/server/sessionKeys.ts +25 -0
  154. package/src/server/sessionProfile.ts +5 -0
  155. package/src/server/tracing.ts +68 -0
  156. package/src/server/transportHeaders.ts +8 -0
  157. package/src/server/trustedProxy.ts +22 -0
  158. package/src/server/trustedProxyRanges.ts +5 -0
@@ -0,0 +1,35 @@
1
+ import type { AuthContext } from "better-auth";
2
+ import type { testUtils } from "better-auth/plugins";
3
+
4
+ type TestContext = ReturnType<ReturnType<typeof testUtils>["init"]>["context"];
5
+ type AccountContext = Pick<AuthContext, "internalAdapter">;
6
+
7
+ export async function signedInUser(
8
+ auth: { readonly $context: Promise<TestContext & AccountContext> },
9
+ {
10
+ providerAccounts = [],
11
+ ...fields
12
+ }: Parameters<TestContext["test"]["createUser"]>[0] & {
13
+ readonly providerAccounts?: readonly { readonly providerId: string; readonly accountId: string }[];
14
+ },
15
+ ) {
16
+ const { test, internalAdapter } = await auth.$context;
17
+ const user = await test.saveUser(test.createUser(fields));
18
+ for (const { providerId, accountId } of providerAccounts) {
19
+ await internalAdapter.createAccount({ accountId, providerId, userId: user.id });
20
+ }
21
+ return test.login({ userId: user.id });
22
+ }
23
+
24
+ export async function storedProviderAccounts(auth: { readonly $context: Promise<AccountContext> }, userId: string) {
25
+ const { internalAdapter } = await auth.$context;
26
+ const accounts = await internalAdapter.findAccounts(userId);
27
+ return accounts.map(({ providerId, accountId, accessToken, refreshToken, idToken, password }) => ({
28
+ accountId,
29
+ hasAccessToken: Boolean(accessToken),
30
+ hasIdToken: Boolean(idToken),
31
+ hasPassword: Boolean(password),
32
+ hasRefreshToken: Boolean(refreshToken),
33
+ providerId,
34
+ }));
35
+ }
@@ -0,0 +1,37 @@
1
+ import type { Auth } from "better-auth";
2
+ import { Effect } from "effect";
3
+ import { redact } from "@shivaedev/errors/redact.ts";
4
+ import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
5
+
6
+ export interface UserProfile {
7
+ readonly id: string;
8
+ readonly image: string | null;
9
+ readonly name: string;
10
+ }
11
+
12
+ export const userProfiles = Effect.fn("Platform.userProfiles")(function* (
13
+ auth: { readonly $context: Promise<{ readonly adapter: Pick<Awaited<Auth["$context"]>["adapter"], "findMany"> }> },
14
+ userIds: readonly string[],
15
+ ): Effect.fn.Return<readonly UserProfile[], AuthUnavailable> {
16
+ const ids = [...new Set(userIds)];
17
+ if (ids.length === 0) {
18
+ return [];
19
+ }
20
+ const users = yield* Effect.tryPromise(async () => {
21
+ const { adapter } = await auth.$context;
22
+ return adapter.findMany<UserProfile>({
23
+ limit: ids.length,
24
+ model: "user",
25
+ select: ["id", "name", "image"],
26
+ where: [{ field: "id", operator: "in", value: ids }],
27
+ });
28
+ }).pipe(
29
+ Effect.catch((error) =>
30
+ Effect.andThen(
31
+ Effect.logError("Authentication provider failed", redact(error.cause)),
32
+ Effect.fail(new AuthUnavailable({ message: "Authentication is temporarily unavailable" })),
33
+ ),
34
+ ),
35
+ );
36
+ return users.map(({ id, name, image }) => ({ id, image: image ?? null, name }));
37
+ });
@@ -0,0 +1,52 @@
1
+ import { Context, Effect, Option } from "effect";
2
+ import * as Reactivity from "effect/reactivity/Reactivity";
3
+ import type { Rpc } from "effect/rpc";
4
+ import { SqlClient } from "effect/sql";
5
+ import { invalidationKeys } from "@shivaedev/contract/keys.ts";
6
+ import { CommandInvalidation, KeyScope } from "@shivaedev/contract/serverMetadata.ts";
7
+ import { ChangeObserver, invalidateOnCommit, transact } from "@shivaedev/sql/transact.ts";
8
+ import { sessionKeyContext } from "#server/sessionKeys.ts";
9
+
10
+ export function sessionCommandChanges<A, E, R>(effect: Effect.Effect<A, E, R>, rpc: Rpc.AnyWithProps, payload: unknown) {
11
+ return Option.isSome(Context.getOption(rpc.annotations, KeyScope)) ? commandChanges(effect, rpc, payload) : effect;
12
+ }
13
+
14
+ export function commandChanges<A, E, R>(effect: Effect.Effect<A, E, R>, rpc: Rpc.AnyWithProps, payload: unknown) {
15
+ const invalidates = Context.getOption(rpc.annotations, CommandInvalidation);
16
+ if (Option.isNone(invalidates)) {
17
+ return effect;
18
+ }
19
+ const declared = invalidates.value;
20
+ return Effect.gen(function* () {
21
+ const context = yield* sessionKeyContext(rpc);
22
+ const sql = yield* Effect.serviceOption(SqlClient.SqlClient);
23
+ const reactivity = yield* Effect.serviceOption(Reactivity.Reactivity);
24
+ if (Option.isNone(reactivity)) {
25
+ return yield* effect;
26
+ }
27
+ const published = new Set<unknown>();
28
+ const observer = yield* ChangeObserver;
29
+ const result = yield* effect.pipe(
30
+ Effect.provideService(ChangeObserver, (event) => {
31
+ if (event._tag === "Published") {
32
+ for (const key of event.changes) {
33
+ published.add(key);
34
+ }
35
+ }
36
+ return observer(event);
37
+ }),
38
+ );
39
+ const keys = invalidationKeys(declared(payload, result), context).filter((key) => !published.has(key));
40
+ if (keys.length > 0) {
41
+ const announce = Option.isNone(sql)
42
+ ? reactivity.value.invalidate(keys)
43
+ : invalidateOnCommit(keys).pipe(
44
+ transact({ onSqlError: (error) => error }),
45
+ Effect.provideService(SqlClient.SqlClient, sql.value),
46
+ Effect.provideService(Reactivity.Reactivity, reactivity.value),
47
+ );
48
+ yield* announce.pipe(Effect.catchCause((cause) => Effect.logError("Committed command invalidation failed", cause)));
49
+ }
50
+ return result;
51
+ });
52
+ }
@@ -0,0 +1,100 @@
1
+ import { Effect, Option } from "effect";
2
+ import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/http";
3
+ import { RpcSerialization } from "effect/rpc";
4
+ import { proxyAddresses, proxyMatches } from "./trustedProxy.ts";
5
+ import { trustedProxyRanges as normalizedRanges } from "./trustedProxyRanges.ts";
6
+
7
+ function loopbackAddress(address: string): boolean {
8
+ const unmapped = unmapAddress(address);
9
+ return unmapped === "::1" || (/^127(?:\.\d{1,3}){3}$/u.test(unmapped) && unmapped.split(".").every((part) => Number(part) <= 255));
10
+ }
11
+ function unmapAddress(address: string): string {
12
+ return address.replace(/^::ffff:/iu, "");
13
+ }
14
+ function loopbackHost(host: string | undefined): boolean {
15
+ if (host === undefined) {
16
+ return false;
17
+ }
18
+ const authority = /^(?<hostname>\[[^\]]+\]|[^:]+)(?::(?<port>\d{1,5}))?$/u.exec(host)?.groups;
19
+ if (authority === undefined || (authority.port !== undefined && Number(authority.port) > 65_535)) {
20
+ return false;
21
+ }
22
+ const hostname = authority.hostname?.toLowerCase();
23
+ return hostname !== undefined && (hostname === "localhost" || hostname === "[::1]" || loopbackAddress(hostname));
24
+ }
25
+ export function sameOrigin(request: HttpServerRequest.HttpServerRequest): boolean {
26
+ const origin = request.headers.origin;
27
+ if (origin === undefined) {
28
+ return true;
29
+ }
30
+ try {
31
+ const url = new URL(origin);
32
+ return url.protocol === "http:" && url.origin === origin && url.host === request.headers.host;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ export function loopbackOnly<E, R>(
38
+ handler: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
39
+ ) {
40
+ return (request: HttpServerRequest.HttpServerRequest) =>
41
+ loopbackHost(request.headers.host) && Option.match(request.remoteAddress, { onNone: () => true, onSome: loopbackAddress })
42
+ ? handler(request)
43
+ : Effect.succeed(HttpServerResponse.text("Only loopback hosts are served", { status: 403 }));
44
+ }
45
+ export function trustedProxyPeer(trustedProxy?: string): (address: string) => boolean {
46
+ const addresses = trustedProxy === undefined ? undefined : proxyAddresses(trustedProxy);
47
+ return (address) => addresses !== undefined && proxyMatches(addresses, address);
48
+ }
49
+ export function trustedProxyRanges(trustedProxy?: string): string[] {
50
+ return normalizedRanges(trustedProxy);
51
+ }
52
+
53
+ function proxyOrigin(request: HttpServerRequest.HttpServerRequest, isTrustedProxy: (address: string) => boolean, native: boolean): boolean {
54
+ if (!Option.exists(request.remoteAddress, isTrustedProxy)) {
55
+ return false;
56
+ }
57
+ const host = request.headers["x-forwarded-host"];
58
+ const protocol = request.headers["x-forwarded-proto"];
59
+ if (host === undefined || !/^(?:\[[\da-f:]+\]|[\da-z.-]+)(?::\d{1,5})?$/iu.test(host) || (protocol !== "http" && protocol !== "https")) {
60
+ return false;
61
+ }
62
+ try {
63
+ const url = new URL(`${protocol}://${host}`);
64
+ return native || request.headers.origin === undefined || request.headers.origin === url.origin;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function localTransport(trustedProxy?: string, nativeOrigins: readonly string[] = []) {
70
+ const isTrustedProxy = trustedProxyPeer(trustedProxy);
71
+ return (request: HttpServerRequest.HttpServerRequest): boolean => {
72
+ const native = nativeOrigins.includes(request.headers.origin ?? "");
73
+ const local = loopbackHost(request.headers.host) && Option.exists(request.remoteAddress, loopbackAddress);
74
+ return (local && (native || sameOrigin(request))) || proxyOrigin(request, isTrustedProxy, native);
75
+ };
76
+ }
77
+ export function localRpcGuard<E, R>(
78
+ effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
79
+ trustedProxy?: string,
80
+ nativeOrigins: readonly string[] = [],
81
+ ) {
82
+ const allowed = localTransport(trustedProxy, nativeOrigins);
83
+ return Effect.gen(function* () {
84
+ const request = yield* HttpServerRequest.HttpServerRequest;
85
+ const serialization = yield* RpcSerialization.RpcSerialization;
86
+ const contentType = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase();
87
+ return allowed(request) && contentType === serialization.contentType
88
+ ? yield* continueHttpTrace(effect, request)
89
+ : HttpServerResponse.text("Local same-origin requests only", { status: 403 });
90
+ });
91
+ }
92
+
93
+ const continueHttpTrace = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>, request: HttpServerRequest.HttpServerRequest) {
94
+ const incoming = HttpTraceContext.w3c(request.headers);
95
+ if (Option.isNone(incoming)) {
96
+ return yield* effect;
97
+ }
98
+ const parent = Option.getOrElse(yield* Effect.option(Effect.currentParentSpan), () => incoming.value);
99
+ return yield* Effect.withParentSpan(effect, parent);
100
+ });
@@ -0,0 +1,26 @@
1
+ import { Option } from "effect";
2
+ import { Headers } from "effect/http";
3
+ export interface OriginRequest {
4
+ readonly headers: Headers.Headers;
5
+ readonly origin: Option.Option<string>;
6
+ readonly rpc: string;
7
+ }
8
+ export type OriginPolicy = (request: OriginRequest) => boolean;
9
+ export interface TrustedOriginsOptions {
10
+ readonly allow: readonly string[];
11
+ readonly missing: "allow" | "reject";
12
+ }
13
+ export function trustedOrigins(options: TrustedOriginsOptions): OriginPolicy {
14
+ return ({ origin }) =>
15
+ Option.match(origin, {
16
+ onNone: () => options.missing === "allow",
17
+ onSome: (value) => options.allow.includes(value),
18
+ });
19
+ }
20
+ export function originRequest(headers: Headers.Headers, rpc: string): OriginRequest {
21
+ return {
22
+ headers,
23
+ origin: Headers.get(headers, "origin"),
24
+ rpc,
25
+ };
26
+ }
@@ -0,0 +1,51 @@
1
+ import { Effect, Layer, Option } from "effect";
2
+ import { type HttpServerRequest, HttpServerResponse } from "effect/http";
3
+ import type { RpcMiddleware } from "effect/rpc";
4
+ import type { Permission } from "@shivaedev/access/permission.ts";
5
+ import { type AuthUnavailable, Forbidden, type Unauthorized } from "@shivaedev/errors/taxonomy.ts";
6
+ import { Identity, type SessionUser } from "#identity.ts";
7
+ import { hasPermission, type permissionMiddleware, requirePermission } from "#permission.ts";
8
+ import { sessionCommandChanges } from "#server/commandChanges.ts";
9
+ import { revalidateStream } from "./revalidateStream.ts";
10
+ import { resolveSession, type SessionPolicy, type UserSessionShape, withSessionUser } from "./session.ts";
11
+
12
+ export function permissionLayer<TMiddleware, TSession extends UserSessionShape>(
13
+ middleware: ReturnType<ReturnType<typeof permissionMiddleware<TMiddleware>>>,
14
+ policy: SessionPolicy<TSession>,
15
+ ) {
16
+ const permission = middleware.permission;
17
+ const guard: RpcMiddleware.RpcMiddleware<Identity | SessionUser, Unauthorized | AuthUnavailable | Forbidden, never> = (
18
+ effect,
19
+ { headers, rpc, payload },
20
+ ) => {
21
+ const lookup = Effect.tap(resolveSession(policy, headers, rpc._tag), (session) =>
22
+ Option.isSome(session) && !hasPermission(session.value.user, permission)
23
+ ? Effect.fail(new Forbidden({ message: `Permission required: ${permission.name} (${permission.strength})` }))
24
+ : Effect.void,
25
+ );
26
+ return sessionCommandChanges(effect, rpc, payload).pipe(
27
+ (invocation) => Effect.flatMap(Identity, ({ id }) => invocation.pipe(revalidateStream(policy, rpc, id, lookup))),
28
+ (invocation) => Effect.andThen(requirePermission(permission), invocation),
29
+ withSessionUser(policy, headers, rpc._tag),
30
+ );
31
+ };
32
+ return Layer.succeed(middleware, guard);
33
+ }
34
+ function rejection(error: Unauthorized | Forbidden | AuthUnavailable) {
35
+ return HttpServerResponse.empty({
36
+ headers: { "cache-control": "private, no-store" },
37
+ status: { AuthUnavailable: 503, Forbidden: 403, Unauthorized: 401 }[error._tag],
38
+ });
39
+ }
40
+
41
+ export function permissionRoute<TSession extends UserSessionShape>(policy: SessionPolicy<TSession>, permission: Permission) {
42
+ return <E, R>(handler: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
43
+ (request: HttpServerRequest.HttpServerRequest): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Exclude<R, Identity | SessionUser>> =>
44
+ Effect.andThen(requirePermission(permission), Effect.context<Identity | SessionUser>()).pipe(
45
+ withSessionUser(policy, request.headers, request.url),
46
+ Effect.matchEffect({
47
+ onFailure: (error) => Effect.succeed(rejection(error)),
48
+ onSuccess: (context) => Effect.provideContext(handler(request), context),
49
+ }),
50
+ );
51
+ }
@@ -0,0 +1,61 @@
1
+ import { BlockList, isIP } from "node:net";
2
+
3
+ export function addProxyAddress(addresses: BlockList, entry: string): boolean {
4
+ const parts = entry.trim().split("/");
5
+ const address = parts[0] ?? "";
6
+ const family = isIP(address);
7
+ if (family === 0 || address.includes("%") || parts.length > 2) {
8
+ return false;
9
+ }
10
+ const type = family === 4 ? "ipv4" : "ipv6";
11
+ const prefix = parts[1];
12
+ if (prefix === undefined) {
13
+ addresses.addAddress(address, type);
14
+ } else {
15
+ if (!/^\d+$/u.test(prefix) || Number(prefix) > (family === 4 ? 32 : 128)) {
16
+ return false;
17
+ }
18
+ addresses.addSubnet(address, Number(prefix), type);
19
+ }
20
+ return true;
21
+ }
22
+ function mappedIpv4(address: string): string | undefined {
23
+ const groups = /^\[::ffff:(?<high>[\da-f]+):(?<low>[\da-f]+)\]$/u.exec(new URL(`http://[${address}]`).hostname)?.groups;
24
+ if (groups?.high === undefined || groups.low === undefined) {
25
+ return undefined;
26
+ }
27
+ const high = Number.parseInt(groups.high, 16);
28
+ const low = Number.parseInt(groups.low, 16);
29
+ return [high >>> 8, high & 255, low >>> 8, low & 255].join(".");
30
+ }
31
+ function normalizedProxyRange(entry: string): string[] | undefined {
32
+ const range = new BlockList();
33
+ if (!addProxyAddress(range, entry)) {
34
+ return undefined;
35
+ }
36
+ const [address = "", prefix] = entry.split("/");
37
+ if (isIP(address) !== 6) {
38
+ return [entry];
39
+ }
40
+ const ipv4 = mappedIpv4(address);
41
+ const bits = prefix === undefined ? 128 : Number(prefix);
42
+ if (ipv4 !== undefined) {
43
+ if (bits >= 96) {
44
+ return [prefix === undefined ? ipv4 : `${ipv4}/${bits - 96}`];
45
+ }
46
+ const network = bits <= 80 ? "::" : `::${((0xff_ff << (96 - bits)) & 0xff_ff).toString(16)}:0:0`;
47
+ return [`${network}/${bits}`, "0.0.0.0/0"];
48
+ }
49
+ return bits < 96 && range.check("::ffff:0:0", "ipv6") ? [entry, "0.0.0.0/0"] : [entry];
50
+ }
51
+ export function proxyRanges(value: string): string[] | undefined {
52
+ const ranges: string[] = [];
53
+ for (const entry of value.split(",")) {
54
+ const normalized = normalizedProxyRange(entry.trim());
55
+ if (normalized === undefined) {
56
+ return undefined;
57
+ }
58
+ ranges.push(...normalized);
59
+ }
60
+ return ranges;
61
+ }
@@ -0,0 +1,7 @@
1
+ import { Effect } from "effect";
2
+ import type { RoleSet } from "@shivaedev/access/roleSet.ts";
3
+ import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
4
+
5
+ export function resolveRole(roles: RoleSet, stored: unknown) {
6
+ return roles.resolve(stored).pipe(Effect.mapError(() => new AuthUnavailable({ message: "Stored user role is not declared by the application" })));
7
+ }
@@ -0,0 +1,25 @@
1
+ import { Effect, Option } from "effect";
2
+ import { type Rpc, RpcSchema } from "effect/rpc";
3
+ import { type AuthUnavailable, type Forbidden, Unauthorized } from "@shivaedev/errors/taxonomy.ts";
4
+ import type { SessionPolicy, SessionShape } from "./session.ts";
5
+
6
+ export function revalidateStream<TSession extends SessionShape>(
7
+ policy: SessionPolicy<TSession>,
8
+ rpc: Rpc.AnyWithProps,
9
+ id: string,
10
+ lookup: Effect.Effect<Option.Option<TSession>, AuthUnavailable | Forbidden>,
11
+ ) {
12
+ return <A, E, R>(effect: Effect.Effect<A, E, R>) => {
13
+ if (!RpcSchema.isStreamSchema(rpc.successSchema)) {
14
+ return effect;
15
+ }
16
+ const check = Effect.gen(function* () {
17
+ yield* Effect.sleep(policy.streamRevalidationInterval ?? "30 seconds");
18
+ const session = yield* lookup.pipe(Effect.catchTag("AuthUnavailable", () => Effect.succeed(undefined)));
19
+ if (session !== undefined && (Option.isNone(session) || session.value.user.id !== id)) {
20
+ return yield* new Unauthorized({ message: "Authentication required" });
21
+ }
22
+ });
23
+ return Effect.raceFirst(effect, Effect.forever(check));
24
+ };
25
+ }
@@ -0,0 +1,148 @@
1
+ import { Duration, Effect, Exit, Layer, Option } from "effect";
2
+ import type { Headers } from "effect/http";
3
+ import type { Permission } from "@shivaedev/access/permission.ts";
4
+ import type { RoleSet } from "@shivaedev/access/roleSet.ts";
5
+ import { type AuthUnavailable, Forbidden, Unauthorized } from "@shivaedev/errors/taxonomy.ts";
6
+ import {
7
+ Identity,
8
+ OptionalIdentity,
9
+ SessionUser,
10
+ type SessionUserValue,
11
+ SessionUserWithProviderAccounts,
12
+ type SessionUserWithProviderAccountsValue,
13
+ } from "#identity.ts";
14
+ import { Authenticated, AuthenticatedUser, AuthenticatedUserWithProviderAccounts, MaybeAuthenticated } from "#middleware.ts";
15
+ import { sessionCommandChanges } from "#server/commandChanges.ts";
16
+ import { type OriginPolicy, originRequest } from "./origin.ts";
17
+ import { resolveRole } from "./resolveRole.ts";
18
+ import { revalidateStream } from "./revalidateStream.ts";
19
+ import { sessionProfile } from "./sessionProfile.ts";
20
+ import { transportHeaders } from "./transportHeaders.ts";
21
+ export interface SessionShape {
22
+ readonly user: {
23
+ readonly id: string;
24
+ };
25
+ }
26
+ export interface SessionProvider<TSession extends SessionShape> {
27
+ readonly get: (headers: Headers.Headers) => Effect.Effect<Option.Option<TSession>, AuthUnavailable>;
28
+ }
29
+ export interface SessionPolicy<TSession extends SessionShape> {
30
+ readonly origin: OriginPolicy;
31
+ readonly provider: SessionProvider<TSession>;
32
+ readonly roles?: RoleSet;
33
+ readonly streamRevalidationInterval?: Duration.Input | undefined;
34
+ }
35
+ export const resolveSession: <TSession extends SessionShape>(
36
+ policy: SessionPolicy<TSession>,
37
+ headers: Headers.Headers,
38
+ rpc: string,
39
+ ) => Effect.Effect<Option.Option<TSession & { readonly user: { readonly permissions?: readonly Permission[] } }>, AuthUnavailable | Forbidden> =
40
+ Effect.fn("PlatformRpc.resolveSession")(function* <TSession extends SessionShape>(
41
+ policy: SessionPolicy<TSession>,
42
+ headers: Headers.Headers,
43
+ rpc: string,
44
+ ) {
45
+ const transport = yield* transportHeaders(headers);
46
+ if (!policy.origin(originRequest(transport, rpc))) {
47
+ return yield* new Forbidden({ message: "Origin not allowed" });
48
+ }
49
+ const session = yield* policy.provider.get(transport);
50
+ if (Option.isNone(session) || policy.roles === undefined) {
51
+ return session;
52
+ }
53
+ const access = yield* resolveRole(policy.roles, Reflect.get(session.value.user, "role"));
54
+ return Option.some({ ...session.value, user: { ...session.value.user, ...access } });
55
+ });
56
+ function identified(id: string) {
57
+ return Effect.andThen(Effect.annotateCurrentSpan("user.id", id), Effect.succeed(id));
58
+ }
59
+ export function authenticatedLayer<TSession extends SessionShape>(policy: SessionPolicy<TSession>): Layer.Layer<Authenticated> {
60
+ return Layer.succeed(Authenticated, (effect, { headers, rpc, payload }) =>
61
+ Effect.gen(function* () {
62
+ const session = yield* resolveSession(policy, headers, rpc._tag);
63
+ if (Option.isNone(session)) {
64
+ return yield* new Unauthorized({ message: "Authentication required" });
65
+ }
66
+ const id = yield* identified(session.value.user.id);
67
+ return yield* sessionCommandChanges(effect, rpc, payload).pipe(
68
+ revalidateStream(policy, rpc, id, resolveSession(policy, headers, rpc._tag)),
69
+ Effect.provideService(Identity, { id }),
70
+ Effect.annotateLogs({ userId: id }),
71
+ );
72
+ }),
73
+ );
74
+ }
75
+ export function maybeAuthenticatedLayer<TSession extends SessionShape>(policy: SessionPolicy<TSession>): Layer.Layer<MaybeAuthenticated> {
76
+ return Layer.succeed(MaybeAuthenticated, (effect, { headers, rpc }) =>
77
+ Effect.gen(function* () {
78
+ const session = yield* resolveSession(policy, headers, rpc._tag);
79
+ if (Option.isNone(session)) {
80
+ return yield* Effect.provideService(effect, OptionalIdentity, Option.none());
81
+ }
82
+ const id = yield* identified(session.value.user.id);
83
+ return yield* effect.pipe(
84
+ revalidateStream(policy, rpc, id, resolveSession(policy, headers, rpc._tag)),
85
+ Effect.provideService(OptionalIdentity, Option.some({ id })),
86
+ Effect.annotateLogs({ userId: id }),
87
+ );
88
+ }),
89
+ );
90
+ }
91
+ export interface UserSessionShape extends SessionShape {
92
+ readonly user: SessionUserValue;
93
+ }
94
+ export function withSessionUser<TSession extends UserSessionShape>(policy: SessionPolicy<TSession>, headers: Headers.Headers, operation: string) {
95
+ return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
96
+ Effect.gen(function* () {
97
+ const session = yield* resolveSession(policy, headers, operation);
98
+ if (Option.isNone(session)) {
99
+ return yield* new Unauthorized({ message: "Authentication required" });
100
+ }
101
+ const user = sessionProfile(session.value.user);
102
+ const id = yield* identified(session.value.user.id);
103
+ return yield* effect.pipe(
104
+ Effect.provideService(Identity, { id }),
105
+ Effect.provideService(SessionUser, user),
106
+ Effect.annotateLogs({ userId: id }),
107
+ );
108
+ });
109
+ }
110
+ export function authenticatedUserLayer<TSession extends UserSessionShape>(policy: SessionPolicy<TSession>): Layer.Layer<AuthenticatedUser> {
111
+ return Layer.succeed(AuthenticatedUser, (effect, { headers, rpc, payload }) =>
112
+ sessionCommandChanges(effect, rpc, payload).pipe(
113
+ (invocation) =>
114
+ Effect.flatMap(Identity, ({ id }) => invocation.pipe(revalidateStream(policy, rpc, id, resolveSession(policy, headers, rpc._tag)))),
115
+ withSessionUser(policy, headers, rpc._tag),
116
+ ),
117
+ );
118
+ }
119
+
120
+ export interface ProviderAccountsSessionShape extends UserSessionShape {
121
+ readonly user: SessionUserWithProviderAccountsValue;
122
+ }
123
+ export function authenticatedUserWithProviderAccountsLayer<TSession extends ProviderAccountsSessionShape>(
124
+ policy: SessionPolicy<TSession>,
125
+ ): Layer.Layer<AuthenticatedUserWithProviderAccounts> {
126
+ return Layer.succeed(AuthenticatedUserWithProviderAccounts, (effect, { headers, rpc, payload }) =>
127
+ Effect.gen(function* () {
128
+ const session = yield* resolveSession(policy, headers, rpc._tag);
129
+ if (Option.isNone(session)) {
130
+ return yield* new Unauthorized({ message: "Authentication required" });
131
+ }
132
+ const { id, providerAccounts } = session.value.user;
133
+ const accounts = yield* Effect.cachedWithTTL(
134
+ Effect.map(providerAccounts, (values) => values.map(({ accountId, providerId }) => ({ accountId, providerId }))),
135
+ (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),
136
+ );
137
+ const user = sessionProfile(session.value.user);
138
+ yield* identified(id);
139
+ return yield* sessionCommandChanges(effect, rpc, payload).pipe(
140
+ revalidateStream(policy, rpc, id, resolveSession(policy, headers, rpc._tag)),
141
+ Effect.provideService(Identity, { id }),
142
+ Effect.provideService(SessionUser, user),
143
+ Effect.provideService(SessionUserWithProviderAccounts, { ...user, providerAccounts: accounts }),
144
+ Effect.annotateLogs({ userId: id }),
145
+ );
146
+ }),
147
+ );
148
+ }
@@ -0,0 +1,25 @@
1
+ import { Context, Effect, Option } from "effect";
2
+ import type { Rpc } from "effect/rpc";
3
+ import type { KeyContext } from "@shivaedev/contract/keys.ts";
4
+ import { ChangesReads, KeyScope, type QueryReference } from "@shivaedev/contract/serverMetadata.ts";
5
+ import { Identity } from "#identity.ts";
6
+
7
+ export function sessionKeyContext(rpc: Pick<Rpc.AnyWithProps, "annotations">): Effect.Effect<KeyContext | undefined> {
8
+ if (Option.isNone(Context.getOption(rpc.annotations, KeyScope))) {
9
+ return Effect.succeed(undefined);
10
+ }
11
+ return Effect.gen(function* () {
12
+ const identity = yield* Effect.serviceOption(Identity);
13
+ if (Option.isNone(identity)) {
14
+ return yield* Effect.die(new Error("Session-scoped contract keys require request-local Identity"));
15
+ }
16
+ return { scope: identity.value.id };
17
+ });
18
+ }
19
+
20
+ export function resolveChanges(rpc: Pick<Rpc.AnyWithProps, "annotations">, references: readonly QueryReference[]) {
21
+ return Effect.map(sessionKeyContext(rpc), (context) => ({
22
+ context,
23
+ keys: Context.getUnsafe(rpc.annotations, ChangesReads)(references),
24
+ }));
25
+ }
@@ -0,0 +1,5 @@
1
+ import type { SessionUserValue } from "#identity.ts";
2
+
3
+ export function sessionProfile({ id, name, email, role, permissions }: SessionUserValue): SessionUserValue {
4
+ return { email, id, name, ...(role === undefined ? {} : { role }), ...(permissions === undefined ? {} : { permissions }) };
5
+ }
@@ -0,0 +1,68 @@
1
+ import { Cause, Context, Effect, Exit, Layer, Option, Predicate, Random } from "effect";
2
+ import { Headers } from "effect/http";
3
+ import { KeyScope } from "@shivaedev/contract/serverMetadata.ts";
4
+ import { redact } from "@shivaedev/errors/redact.ts";
5
+ import { redactCause } from "@shivaedev/errors/redactCause.ts";
6
+ import { isSensitiveKey, REDACTED, redactText, type SensitiveKey } from "@shivaedev/errors/sensitive.ts";
7
+ import { RequestId } from "#identity.ts";
8
+ import { RequestTracing } from "#middleware.ts";
9
+ import { commandChanges } from "#server/commandChanges.ts";
10
+ export interface RequestTracingOptions {
11
+ readonly header?: string;
12
+ readonly sensitive?: SensitiveKey;
13
+ }
14
+ const ACCEPTED_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/u;
15
+ function hex(byte: number): string {
16
+ return byte.toString(16).padStart(2, "0");
17
+ }
18
+ const generateRequestId = Effect.map(Effect.all(Array.from({ length: 16 }, () => Random.nextIntBetween(0, 255))), (bytes) => bytes.map(hex).join(""));
19
+ function requestIdOf(headers: Headers.Headers, header: string): Effect.Effect<string> {
20
+ return Option.match(
21
+ Option.filter(Headers.get(headers, header), (value) => ACCEPTED_REQUEST_ID.test(value)),
22
+ {
23
+ onNone: () => generateRequestId,
24
+ onSome: Effect.succeed,
25
+ },
26
+ );
27
+ }
28
+ function failureTag(cause: Cause.Cause<unknown>, sensitive: SensitiveKey = isSensitiveKey): string {
29
+ return Option.match(Cause.findErrorOption(cause), {
30
+ onNone: () => "unknown",
31
+ onSome: (error) => {
32
+ if (!(Predicate.hasProperty(error, "_tag") && Predicate.isString(error._tag))) {
33
+ return "unknown";
34
+ }
35
+ return sensitive("_tag") ? REDACTED : redactText(error._tag, sensitive);
36
+ },
37
+ });
38
+ }
39
+ function logFailure(cause: Cause.Cause<unknown>, payload: unknown, sensitive: SensitiveKey | undefined): Effect.Effect<void> {
40
+ if (Cause.hasInterruptsOnly(cause)) {
41
+ return Effect.annotateCurrentSpan("rpc.interrupted", true).pipe(
42
+ Effect.andThen(Effect.logDebug("RPC interrupted")),
43
+ Effect.annotateLogs({ "rpc.payload": redact(payload, sensitive) }),
44
+ );
45
+ }
46
+ const annotations = { "rpc.payload": redact(payload, sensitive) };
47
+ if (!Cause.hasDies(cause)) {
48
+ return Effect.annotateLogs(Effect.logInfo("RPC failure"), { ...annotations, "rpc.failure": failureTag(cause, sensitive) });
49
+ }
50
+ const defects = cause.reasons.filter(Cause.isDieReason).map((reason) => redact(reason.defect, sensitive));
51
+ return Effect.annotateLogs(Effect.logError("RPC defect"), { ...annotations, "rpc.defect": defects });
52
+ }
53
+ export function requestTracingLayer(options: RequestTracingOptions = {}): Layer.Layer<RequestTracing> {
54
+ return Layer.succeed(RequestTracing, (effect, { headers, payload, rpc }) =>
55
+ Effect.gen(function* () {
56
+ const requestId = yield* requestIdOf(headers, options.header ?? "x-request-id");
57
+ yield* Effect.annotateCurrentSpan({ "request.id": requestId, "rpc.method": rpc._tag });
58
+ const invocation = Option.isSome(Context.getOption(rpc.annotations, KeyScope)) ? effect : commandChanges(effect, rpc, payload);
59
+ return yield* invocation.pipe(
60
+ Effect.catchCauseIf(Cause.hasDies, (cause) => Effect.failCause(redactCause(cause, options.sensitive))),
61
+ Effect.onExit((exit) => (Exit.isFailure(exit) ? logFailure(exit.cause, payload, options.sensitive) : Effect.void)),
62
+ Effect.provideService(RequestId, requestId),
63
+ Effect.annotateLogs({ requestId, "rpc.method": rpc._tag }),
64
+ Effect.withLogSpan("rpc.duration"),
65
+ );
66
+ }),
67
+ );
68
+ }
@@ -0,0 +1,8 @@
1
+ import { Effect, Option } from "effect";
2
+ import { type Headers, HttpServerRequest } from "effect/http";
3
+ export function transportHeaders(rpcHeaders: Headers.Headers): Effect.Effect<Headers.Headers> {
4
+ return Effect.map(
5
+ Effect.serviceOption(HttpServerRequest.HttpServerRequest),
6
+ Option.match({ onNone: () => rpcHeaders, onSome: (request) => request.headers }),
7
+ );
8
+ }