@shivaedev/platform 0.25.0 → 0.28.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.
- package/CHANGELOG.md +57 -0
- package/README.md +76 -3
- package/dist/http-client/body.d.ts +7 -0
- package/dist/http-client/body.d.ts.map +1 -0
- package/dist/http-client/body.js +32 -0
- package/dist/http-client/body.js.map +1 -0
- package/dist/http-client/errors.d.ts +51 -0
- package/dist/http-client/errors.d.ts.map +1 -0
- package/dist/http-client/errors.js +25 -0
- package/dist/http-client/errors.js.map +1 -0
- package/dist/http-client/filterStatus.d.ts +4 -0
- package/dist/http-client/filterStatus.d.ts.map +1 -0
- package/dist/http-client/filterStatus.js +38 -0
- package/dist/http-client/filterStatus.js.map +1 -0
- package/dist/http-client/nextLink.d.ts +2 -0
- package/dist/http-client/nextLink.d.ts.map +1 -0
- package/dist/http-client/nextLink.js +11 -0
- package/dist/http-client/nextLink.js.map +1 -0
- package/dist/http-client/paginate.d.ts +5 -0
- package/dist/http-client/paginate.d.ts.map +1 -0
- package/dist/http-client/paginate.js +31 -0
- package/dist/http-client/paginate.js.map +1 -0
- package/dist/http-client/parseRetryAfter.d.ts +3 -0
- package/dist/http-client/parseRetryAfter.d.ts.map +1 -0
- package/dist/http-client/parseRetryAfter.js +17 -0
- package/dist/http-client/parseRetryAfter.js.map +1 -0
- package/dist/rpc/publicConfig.d.ts +5 -9
- package/dist/rpc/publicConfig.d.ts.map +1 -1
- package/dist/server/MigrationOptions.d.ts +2 -0
- package/dist/server/MigrationOptions.d.ts.map +1 -1
- package/dist/server/appHost.d.ts +1 -1
- package/dist/server/better-auth/mount.d.ts +2 -2
- package/dist/server/better-auth/mount.d.ts.map +1 -1
- package/dist/server/better-auth/mount.js +2 -2
- package/dist/server/better-auth/mount.js.map +1 -1
- package/dist/server/better-auth/testing.d.ts +20 -3
- package/dist/server/better-auth/testing.d.ts.map +1 -1
- package/dist/server/better-auth/testing.js +17 -2
- package/dist/server/better-auth/testing.js.map +1 -1
- package/dist/server/better-auth/userProfiles.d.ts +14 -0
- package/dist/server/better-auth/userProfiles.d.ts.map +1 -0
- package/dist/server/better-auth/userProfiles.js +20 -0
- package/dist/server/better-auth/userProfiles.js.map +1 -0
- package/dist/server/databaseLayer.d.ts.map +1 -1
- package/dist/server/databaseLayer.js +42 -10
- package/dist/server/databaseLayer.js.map +1 -1
- package/dist/server/hostChanges.d.ts.map +1 -1
- package/dist/server/hostChanges.js +4 -2
- package/dist/server/hostChanges.js.map +1 -1
- package/dist/server/rpc-server/local.d.ts.map +1 -1
- package/dist/server/rpc-server/local.js +3 -2
- package/dist/server/rpc-server/local.js.map +1 -1
- package/dist/server/rpc-server/proxyRanges.d.ts +4 -0
- package/dist/server/rpc-server/proxyRanges.d.ts.map +1 -0
- package/dist/server/rpc-server/proxyRanges.js +62 -0
- package/dist/server/rpc-server/proxyRanges.js.map +1 -0
- package/dist/server/rpc-server/trustedProxy.d.ts.map +1 -1
- package/dist/server/rpc-server/trustedProxy.js +2 -58
- package/dist/server/rpc-server/trustedProxy.js.map +1 -1
- package/dist/server/rpc-server/trustedProxyRanges.d.ts +2 -0
- package/dist/server/rpc-server/trustedProxyRanges.d.ts.map +1 -0
- package/dist/server/rpc-server/trustedProxyRanges.js +5 -0
- package/dist/server/rpc-server/trustedProxyRanges.js.map +1 -0
- package/package.json +13 -13
- package/src/http-client/body.ts +37 -0
- package/src/http-client/errors.ts +23 -0
- package/src/http-client/filterStatus.ts +38 -0
- package/src/http-client/nextLink.ts +10 -0
- package/src/http-client/paginate.ts +39 -0
- package/src/http-client/parseRetryAfter.ts +17 -0
- package/src/server/MigrationOptions.ts +2 -0
- package/src/server/better-auth/mount.ts +6 -3
- package/src/server/better-auth/testing.ts +28 -2
- package/src/server/better-auth/userProfiles.ts +37 -0
- package/src/server/databaseLayer.ts +52 -12
- package/src/server/hostChanges.ts +4 -3
- package/src/server/rpc-server/local.ts +4 -2
- package/src/server/rpc-server/proxyRanges.ts +61 -0
- package/src/server/rpc-server/trustedProxy.ts +3 -57
- package/src/server/rpc-server/trustedProxyRanges.ts +5 -0
- package/src/server/testing/makePlatformIt.ts +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect, Schema, Stream } from "effect";
|
|
2
|
+
import type { HttpClientResponse } from "effect/http";
|
|
3
|
+
import { ResponseBodyTooLarge } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
export const readBoundedBody = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse, limit: number) {
|
|
6
|
+
const length = response.headers["content-length"];
|
|
7
|
+
if (length !== undefined && Number(length) > limit) {
|
|
8
|
+
return yield* new ResponseBodyTooLarge({ limit });
|
|
9
|
+
}
|
|
10
|
+
const chunks: Uint8Array[] = [];
|
|
11
|
+
let size = 0;
|
|
12
|
+
yield* response.stream.pipe(
|
|
13
|
+
Stream.runForEach((chunk) => {
|
|
14
|
+
size += chunk.byteLength;
|
|
15
|
+
if (size > limit) {
|
|
16
|
+
return Effect.fail(new ResponseBodyTooLarge({ limit }));
|
|
17
|
+
}
|
|
18
|
+
chunks.push(chunk);
|
|
19
|
+
return Effect.void;
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
const bytes = new Uint8Array(size);
|
|
23
|
+
let offset = 0;
|
|
24
|
+
for (const chunk of chunks) {
|
|
25
|
+
bytes.set(chunk, offset);
|
|
26
|
+
offset += chunk.byteLength;
|
|
27
|
+
}
|
|
28
|
+
return bytes;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function readBoundedText(response: HttpClientResponse.HttpClientResponse, limit: number) {
|
|
32
|
+
return Effect.map(readBoundedBody(response, limit), (bytes) => new TextDecoder().decode(bytes));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readBoundedJson<TSchema extends Schema.Constraint>(response: HttpClientResponse.HttpClientResponse, limit: number, schema: TSchema) {
|
|
36
|
+
return Effect.flatMap(readBoundedText(response, limit), Schema.decodeUnknownEffect(Schema.fromJsonString(schema)));
|
|
37
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const answer = { method: Schema.String, status: Schema.Number, url: Schema.String };
|
|
4
|
+
|
|
5
|
+
export class RemoteUnauthorized extends Schema.TaggedError<RemoteUnauthorized>()("RemoteUnauthorized", answer) {}
|
|
6
|
+
export class RemoteNotFound extends Schema.TaggedError<RemoteNotFound>()("RemoteNotFound", answer) {}
|
|
7
|
+
export class RemoteGone extends Schema.TaggedError<RemoteGone>()("RemoteGone", answer) {}
|
|
8
|
+
export class RemoteRateLimited extends Schema.TaggedError<RemoteRateLimited>()("RemoteRateLimited", {
|
|
9
|
+
...answer,
|
|
10
|
+
retryAfter: Schema.optionalKey(Schema.DateTimeUtc),
|
|
11
|
+
}) {}
|
|
12
|
+
export class RemoteHttpError extends Schema.TaggedError<RemoteHttpError>()("RemoteHttpError", answer) {}
|
|
13
|
+
|
|
14
|
+
export type RemoteStatusError = RemoteUnauthorized | RemoteNotFound | RemoteGone | RemoteRateLimited | RemoteHttpError;
|
|
15
|
+
|
|
16
|
+
export class PaginationLinkError extends Schema.TaggedError<PaginationLinkError>()("PaginationLinkError", {
|
|
17
|
+
reason: Schema.Literals(["cross-origin", "invalid"]),
|
|
18
|
+
url: Schema.String,
|
|
19
|
+
}) {}
|
|
20
|
+
|
|
21
|
+
export class ResponseBodyTooLarge extends Schema.TaggedError<ResponseBodyTooLarge>()("ResponseBodyTooLarge", {
|
|
22
|
+
limit: Schema.Number,
|
|
23
|
+
}) {}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DateTime, Effect } from "effect";
|
|
2
|
+
import { HttpClient, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import { RemoteGone, RemoteHttpError, RemoteNotFound, RemoteRateLimited, type RemoteStatusError, RemoteUnauthorized } from "./errors.ts";
|
|
4
|
+
import { parseRetryAfter } from "./parseRetryAfter.ts";
|
|
5
|
+
|
|
6
|
+
const checkStatus = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse) {
|
|
7
|
+
const { status, request } = response;
|
|
8
|
+
if (status >= 200 && status < 300) {
|
|
9
|
+
return response;
|
|
10
|
+
}
|
|
11
|
+
const fields = { method: request.method, status, url: request.url.split(/[?#]/u)[0] ?? request.url };
|
|
12
|
+
let error: RemoteStatusError;
|
|
13
|
+
switch (status) {
|
|
14
|
+
case 401:
|
|
15
|
+
error = new RemoteUnauthorized(fields);
|
|
16
|
+
break;
|
|
17
|
+
case 404:
|
|
18
|
+
error = new RemoteNotFound(fields);
|
|
19
|
+
break;
|
|
20
|
+
case 410:
|
|
21
|
+
error = new RemoteGone(fields);
|
|
22
|
+
break;
|
|
23
|
+
default: {
|
|
24
|
+
const header = response.headers["retry-after"];
|
|
25
|
+
if (status === 429 || (status === 503 && header !== undefined)) {
|
|
26
|
+
const retryAfter = parseRetryAfter(header, yield* DateTime.now);
|
|
27
|
+
error = new RemoteRateLimited({ ...fields, ...(retryAfter === undefined ? {} : { retryAfter }) });
|
|
28
|
+
} else {
|
|
29
|
+
error = new RemoteHttpError(fields);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return yield* Effect.fail(error);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export function filterStatus<E, R>(client: HttpClient.HttpClient.With<E, R>): HttpClient.HttpClient.With<E | RemoteStatusError, R> {
|
|
37
|
+
return HttpClient.transformResponse(client, Effect.flatMap(checkStatus));
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function nextLink(header: string | undefined): string | undefined {
|
|
2
|
+
for (const link of header?.matchAll(/<(?<url>[^>]*)>(?<parameters>(?:[^<",]|"(?:\\.|[^"\\])*")*)/gu) ?? []) {
|
|
3
|
+
const relation = link.groups?.parameters?.match(/(?:^|;)\s*rel\s*=\s*(?:"(?<quoted>[^"]*)"|(?<unquoted>[^;\s,]+))/iu);
|
|
4
|
+
const relations = relation?.groups?.quoted ?? relation?.groups?.unquoted;
|
|
5
|
+
if (relations?.toLowerCase().split(/\s+/u).includes("next")) {
|
|
6
|
+
return link.groups?.url;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
import { type HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
|
|
3
|
+
import { PaginationLinkError } from "./errors.ts";
|
|
4
|
+
import { nextLink } from "./nextLink.ts";
|
|
5
|
+
|
|
6
|
+
const nextRequest = Effect.fnUntraced(function* (response: HttpClientResponse.HttpClientResponse) {
|
|
7
|
+
const link = nextLink(response.headers.link);
|
|
8
|
+
if (link === undefined) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
const current = response.url || response.request.url;
|
|
12
|
+
const url = current.split(/[?#]/u)[0] ?? current;
|
|
13
|
+
const next = yield* Effect.try({
|
|
14
|
+
catch: () => new PaginationLinkError({ reason: "invalid", url }),
|
|
15
|
+
try: () => new URL(link, current),
|
|
16
|
+
});
|
|
17
|
+
if (next.origin !== new URL(current).origin) {
|
|
18
|
+
return yield* new PaginationLinkError({ reason: "cross-origin", url });
|
|
19
|
+
}
|
|
20
|
+
return HttpClientRequest.setUrl(response.request, next);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export function paginate<E, R>(client: HttpClient.HttpClient.With<E, R>, first: HttpClientRequest.HttpClientRequest) {
|
|
24
|
+
return Stream.unfold<
|
|
25
|
+
HttpClientRequest.HttpClientRequest | HttpClientResponse.HttpClientResponse,
|
|
26
|
+
HttpClientResponse.HttpClientResponse,
|
|
27
|
+
E | PaginationLinkError,
|
|
28
|
+
R
|
|
29
|
+
>(first, (state) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const request = HttpClientRequest.isHttpClientRequest(state) ? state : yield* nextRequest(state);
|
|
32
|
+
if (request === undefined) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const response = yield* client.execute(request);
|
|
36
|
+
return [response, response] as const;
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DateTime, Option } from "effect";
|
|
2
|
+
|
|
3
|
+
const httpDate = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/u;
|
|
4
|
+
|
|
5
|
+
export function parseRetryAfter(header: string | undefined, now: DateTime.Utc): DateTime.Utc | undefined {
|
|
6
|
+
if (header === undefined) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const value = header.trim();
|
|
10
|
+
let millis = Number.NaN;
|
|
11
|
+
if (/^\d+$/u.test(value)) {
|
|
12
|
+
millis = DateTime.toEpochMillis(now) + Number(value) * 1000;
|
|
13
|
+
} else if (httpDate.test(value)) {
|
|
14
|
+
millis = Date.parse(value);
|
|
15
|
+
}
|
|
16
|
+
return Option.getOrUndefined(DateTime.make(millis));
|
|
17
|
+
}
|
|
@@ -8,6 +8,8 @@ export interface MigrationOptions<R = never> {
|
|
|
8
8
|
readonly lockTimeout?: Duration.Input;
|
|
9
9
|
readonly mode?: "migrate" | "check" | "wait";
|
|
10
10
|
readonly pollInterval?: Duration.Input;
|
|
11
|
+
readonly progressInterval?: Duration.Input;
|
|
12
|
+
readonly release?: string;
|
|
11
13
|
readonly table?: string;
|
|
12
14
|
readonly waitTimeout?: Duration.Input;
|
|
13
15
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type BetterAuthOptions, betterAuth } from "better-auth";
|
|
2
2
|
import { Config, Effect, Option, Redacted } from "effect";
|
|
3
3
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
4
|
-
import { Pool, type PoolClient } from "pg";
|
|
4
|
+
import { Pool, type PoolClient, type PoolConfig } from "pg";
|
|
5
5
|
import { type RevokeUnadmitted, revokeUnadmitted } from "#server/better-auth/revokeUnadmitted.ts";
|
|
6
6
|
import { type PolicyStorage, type SignInPolicy, signInGate } from "#server/better-auth/signInGate.ts";
|
|
7
7
|
import { signInPolicyOptions } from "#server/better-auth/signInPolicy.ts";
|
|
@@ -96,12 +96,15 @@ export const betterAuthMount = Effect.fn("Platform.betterAuthMount")(function* <
|
|
|
96
96
|
return { admittedSessions, auth, revokeUnadmitted: revoke, routes };
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
export const authDatabase = Effect.fn("Platform.authDatabase")(function* (
|
|
99
|
+
export const authDatabase = Effect.fn("Platform.authDatabase")(function* (
|
|
100
|
+
url: Redacted.Redacted<string>,
|
|
101
|
+
options: Pick<PoolConfig, "max" | "application_name"> = {},
|
|
102
|
+
) {
|
|
100
103
|
const clients = new Set<PoolClient>();
|
|
101
104
|
const run = Effect.runForkWith(yield* Effect.context<never>());
|
|
102
105
|
return yield* Effect.acquireRelease(
|
|
103
106
|
Effect.sync(() => {
|
|
104
|
-
const pool = new Pool({ connectionString: Redacted.value(url) });
|
|
107
|
+
const pool = new Pool({ ...options, connectionString: Redacted.value(url) });
|
|
105
108
|
pool.on("connect", (client) => clients.add(client));
|
|
106
109
|
pool.on("remove", (client) => clients.delete(client));
|
|
107
110
|
pool.on("error", (error) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthContext } from "better-auth";
|
|
1
2
|
import { testUtils } from "better-auth/plugins";
|
|
2
3
|
|
|
3
4
|
export function authTestUtils(options?: Parameters<typeof testUtils>[0]) {
|
|
@@ -13,9 +14,34 @@ export function authTestUtils(options?: Parameters<typeof testUtils>[0]) {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
type TestContext = ReturnType<ReturnType<typeof authTestUtils>["init"]>["context"];
|
|
17
|
+
type AccountContext = Pick<AuthContext, "internalAdapter">;
|
|
16
18
|
|
|
17
|
-
export async function signedInUser(
|
|
18
|
-
|
|
19
|
+
export async function signedInUser(
|
|
20
|
+
auth: { readonly $context: Promise<TestContext & AccountContext> },
|
|
21
|
+
{
|
|
22
|
+
providerAccounts = [],
|
|
23
|
+
...fields
|
|
24
|
+
}: Parameters<TestContext["test"]["createUser"]>[0] & {
|
|
25
|
+
readonly providerAccounts?: readonly { readonly providerId: string; readonly accountId: string }[];
|
|
26
|
+
},
|
|
27
|
+
) {
|
|
28
|
+
const { test, internalAdapter } = await auth.$context;
|
|
19
29
|
const user = await test.saveUser(test.createUser(fields));
|
|
30
|
+
for (const { providerId, accountId } of providerAccounts) {
|
|
31
|
+
await internalAdapter.createAccount({ accountId, providerId, userId: user.id });
|
|
32
|
+
}
|
|
20
33
|
return test.login({ userId: user.id });
|
|
21
34
|
}
|
|
35
|
+
|
|
36
|
+
export async function storedProviderAccounts(auth: { readonly $context: Promise<AccountContext> }, userId: string) {
|
|
37
|
+
const { internalAdapter } = await auth.$context;
|
|
38
|
+
const accounts = await internalAdapter.findAccounts(userId);
|
|
39
|
+
return accounts.map(({ providerId, accountId, accessToken, refreshToken, idToken, password }) => ({
|
|
40
|
+
accountId,
|
|
41
|
+
hasAccessToken: Boolean(accessToken),
|
|
42
|
+
hasIdToken: Boolean(idToken),
|
|
43
|
+
hasPassword: Boolean(password),
|
|
44
|
+
hasRefreshToken: Boolean(refreshToken),
|
|
45
|
+
providerId,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Auth } from "better-auth";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { AuthUnavailable } from "#errors/taxonomy.ts";
|
|
4
|
+
import { redact } from "#server/rpc-server/redact.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
|
+
});
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { createConnection } from "node:net";
|
|
3
3
|
import * as PgClient from "@effect/sql-pg/PgClient";
|
|
4
|
-
import { Config, Effect, Layer, Redacted, Schedule } from "effect";
|
|
4
|
+
import { Config, Duration, Effect, Fiber, Layer, Redacted, Schedule } from "effect";
|
|
5
5
|
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
6
6
|
import { makeLiveServer } from "@shivaedev/effect-contract/makeLiveServer.ts";
|
|
7
|
-
import { assertPostgresMigrations } from "@shivaedev/effect-sql/assertPostgresMigrations.ts";
|
|
7
|
+
import { assertPostgresMigrations, postgresMigrationError } from "@shivaedev/effect-sql/assertPostgresMigrations.ts";
|
|
8
8
|
import type { SqlNameTransforms } from "@shivaedev/effect-sql/clientOptions.ts";
|
|
9
|
+
import { coordinateWorkerRelease } from "@shivaedev/effect-sql/coordinateWorkerRelease.ts";
|
|
9
10
|
import { migratePostgres } from "@shivaedev/effect-sql/migrations.ts";
|
|
11
|
+
import { type PendingPostgresMigrations, pendingPostgresMigrations } from "@shivaedev/effect-sql/pendingPostgresMigrations.ts";
|
|
10
12
|
import { type PostgresChangesOptions, postgresChanges } from "@shivaedev/effect-sql/postgresChanges.ts";
|
|
13
|
+
import { WorkerRelease } from "@shivaedev/effect-sql/WorkerRelease.ts";
|
|
11
14
|
import type { AppChanges, AppDatabaseOptions } from "#server/appHost.ts";
|
|
12
15
|
import { databaseAddress } from "./databaseAddress.ts";
|
|
13
16
|
|
|
@@ -52,7 +55,12 @@ export function databaseLayer<R>(options: AppDatabaseOptions<R>, changesService:
|
|
|
52
55
|
);
|
|
53
56
|
const prepared = Layer.effectDiscard(prepareMigrations(options.migrations)).pipe(Layer.provideMerge(pool));
|
|
54
57
|
const live = Layer.effect(changesService, makeLiveServer).pipe(Layer.provideMerge(Reactivity.layer));
|
|
55
|
-
const
|
|
58
|
+
const migrations = options.migrations;
|
|
59
|
+
const worker =
|
|
60
|
+
migrations.mode === "wait" && migrations.release !== undefined
|
|
61
|
+
? Layer.effect(WorkerRelease, coordinateWorkerRelease(migrations.release, migrations.pollInterval, migrations.table))
|
|
62
|
+
: Layer.empty;
|
|
63
|
+
const base = Layer.merge(worker.pipe(Layer.provideMerge(prepared)), live);
|
|
56
64
|
const changes = options.config?.changes;
|
|
57
65
|
return Layer.unwrap(
|
|
58
66
|
Effect.gen(function* () {
|
|
@@ -68,20 +76,52 @@ export function databaseLayer<R>(options: AppDatabaseOptions<R>, changesService:
|
|
|
68
76
|
|
|
69
77
|
function prepareMigrations<R>(migrations: AppDatabaseOptions<R>["migrations"]) {
|
|
70
78
|
switch (migrations.mode) {
|
|
71
|
-
case "check":
|
|
72
|
-
|
|
79
|
+
case "check": {
|
|
80
|
+
const schema = { ...migrations };
|
|
81
|
+
delete schema.release;
|
|
82
|
+
return assertPostgresMigrations(schema);
|
|
83
|
+
}
|
|
73
84
|
case "wait":
|
|
74
85
|
return waitForMigrations(migrations);
|
|
75
86
|
default:
|
|
76
|
-
return migratePostgres({ ...migrations, lockTimeout: migrations.lockTimeout ?? "5 seconds" });
|
|
87
|
+
return migratePostgres({ ...migrations, lockTimeout: migrations.lockTimeout ?? "5 seconds" }).pipe(Effect.asVoid);
|
|
77
88
|
}
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
function waitForMigrations<R>(migrations: AppDatabaseOptions<R>["migrations"]) {
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
return Effect.gen(function* () {
|
|
93
|
+
const progressInterval = Duration.fromInputUnsafe(migrations.progressInterval ?? "30 seconds");
|
|
94
|
+
let pending: PendingPostgresMigrations = { components: [], migrations: [] };
|
|
95
|
+
let progress: Fiber.Fiber<never> | undefined;
|
|
96
|
+
function log(message: string) {
|
|
97
|
+
return Effect.suspend(() =>
|
|
98
|
+
Effect.logInfo(message).pipe(Effect.annotateLogs({ pendingComponents: pending.components, pendingMigrations: pending.migrations })),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
yield* Effect.gen(function* () {
|
|
102
|
+
pending = yield* pendingPostgresMigrations(migrations);
|
|
103
|
+
if (pending.migrations.length === 0 && pending.components.length === 0) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (progress === undefined) {
|
|
107
|
+
yield* log("Waiting for database migrations");
|
|
108
|
+
progress = yield* Effect.forever(Effect.sleep(progressInterval).pipe(Effect.andThen(log("Still waiting for database migrations")))).pipe(
|
|
109
|
+
Effect.forkScoped,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return yield* Effect.fail(postgresMigrationError(pending));
|
|
113
|
+
}).pipe(
|
|
114
|
+
Effect.retry({
|
|
115
|
+
schedule: Schedule.max([Schedule.spaced(migrations.pollInterval ?? "1 second"), Schedule.during(migrations.waitTimeout ?? "5 minutes")]),
|
|
116
|
+
while: (error) => error._tag === "MigrationError" && error.kind === "BadState",
|
|
117
|
+
}),
|
|
118
|
+
Effect.tapError((error) =>
|
|
119
|
+
error._tag === "MigrationError" && error.kind === "BadState" ? log("Timed out waiting for database migrations") : Effect.void,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
if (progress !== undefined) {
|
|
123
|
+
yield* Fiber.interrupt(progress);
|
|
124
|
+
yield* Effect.logInfo("Database migrations current");
|
|
125
|
+
}
|
|
126
|
+
}).pipe(Effect.scoped);
|
|
87
127
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Context, Effect, Layer, Option, Stream } from "effect";
|
|
2
2
|
import type { Rpc, RpcGroup } from "effect/rpc";
|
|
3
|
-
import type
|
|
3
|
+
import { ChangesReads, type QueryReference } from "@shivaedev/effect-contract/serverMetadata.ts";
|
|
4
4
|
import type { AppChanges } from "./appHost.ts";
|
|
5
5
|
|
|
6
6
|
export function hostChanges<R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>, changesService: typeof AppChanges) {
|
|
@@ -18,8 +18,9 @@ export function hostChanges<R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>, chan
|
|
|
18
18
|
handlers.set(rpc.key, provided);
|
|
19
19
|
continue;
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const reads = Context.getUnsafe(rpc.annotations, ChangesReads);
|
|
22
|
+
function handler({ queries }: { readonly queries: readonly QueryReference[] }) {
|
|
23
|
+
return Option.isSome(changes) ? changes.value.stream(reads(queries)) : Stream.never;
|
|
23
24
|
}
|
|
24
25
|
handlers.set(rpc.key, { context: services, handler, tag: rpc._tag });
|
|
25
26
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
2
|
import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/http";
|
|
3
3
|
import { RpcSerialization } from "effect/rpc";
|
|
4
|
-
import { proxyAddresses, proxyMatches
|
|
4
|
+
import { proxyAddresses, proxyMatches } from "./trustedProxy.ts";
|
|
5
|
+
import { trustedProxyRanges as normalizedRanges } from "./trustedProxyRanges.ts";
|
|
5
6
|
|
|
6
7
|
function loopbackAddress(address: string): boolean {
|
|
7
8
|
const unmapped = unmapAddress(address);
|
|
@@ -46,8 +47,9 @@ export function trustedProxyPeer(trustedProxy?: string): (address: string) => bo
|
|
|
46
47
|
return (address) => addresses !== undefined && proxyMatches(addresses, address);
|
|
47
48
|
}
|
|
48
49
|
export function trustedProxyRanges(trustedProxy?: string): string[] {
|
|
49
|
-
return
|
|
50
|
+
return normalizedRanges(trustedProxy);
|
|
50
51
|
}
|
|
52
|
+
|
|
51
53
|
function proxyOrigin(request: HttpServerRequest.HttpServerRequest, isTrustedProxy: (address: string) => boolean): boolean {
|
|
52
54
|
if (!Option.exists(request.remoteAddress, isTrustedProxy)) {
|
|
53
55
|
return false;
|
|
@@ -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
|
+
}
|
|
@@ -1,65 +1,11 @@
|
|
|
1
1
|
import { BlockList, isIP } from "node:net";
|
|
2
2
|
import { Schema } from "effect";
|
|
3
|
+
import { addProxyAddress, proxyRanges as normalizedRanges } from "./proxyRanges.ts";
|
|
3
4
|
|
|
4
|
-
function addProxyAddress(addresses: BlockList, entry: string): boolean {
|
|
5
|
-
const parts = entry.trim().split("/");
|
|
6
|
-
const address = parts[0] ?? "";
|
|
7
|
-
const family = isIP(address);
|
|
8
|
-
if (family === 0 || address.includes("%") || parts.length > 2) {
|
|
9
|
-
return false;
|
|
10
|
-
}
|
|
11
|
-
const type = family === 4 ? "ipv4" : "ipv6";
|
|
12
|
-
const prefix = parts[1];
|
|
13
|
-
if (prefix === undefined) {
|
|
14
|
-
addresses.addAddress(address, type);
|
|
15
|
-
} else {
|
|
16
|
-
if (!/^\d+$/u.test(prefix) || Number(prefix) > (family === 4 ? 32 : 128)) {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
addresses.addSubnet(address, Number(prefix), type);
|
|
20
|
-
}
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
23
|
-
function mappedIpv4(address: string): string | undefined {
|
|
24
|
-
const groups = /^\[::ffff:(?<high>[\da-f]+):(?<low>[\da-f]+)\]$/u.exec(new URL(`http://[${address}]`).hostname)?.groups;
|
|
25
|
-
if (groups?.high === undefined || groups.low === undefined) {
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
const high = Number.parseInt(groups.high, 16);
|
|
29
|
-
const low = Number.parseInt(groups.low, 16);
|
|
30
|
-
return [high >>> 8, high & 255, low >>> 8, low & 255].join(".");
|
|
31
|
-
}
|
|
32
|
-
function normalizedProxyRange(entry: string): string[] | undefined {
|
|
33
|
-
const range = new BlockList();
|
|
34
|
-
if (!addProxyAddress(range, entry)) {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
const [address = "", prefix] = entry.split("/");
|
|
38
|
-
if (isIP(address) !== 6) {
|
|
39
|
-
return [entry];
|
|
40
|
-
}
|
|
41
|
-
const ipv4 = mappedIpv4(address);
|
|
42
|
-
const bits = prefix === undefined ? 128 : Number(prefix);
|
|
43
|
-
if (ipv4 !== undefined) {
|
|
44
|
-
if (bits >= 96) {
|
|
45
|
-
return [prefix === undefined ? ipv4 : `${ipv4}/${bits - 96}`];
|
|
46
|
-
}
|
|
47
|
-
const network = bits <= 80 ? "::" : `::${((0xff_ff << (96 - bits)) & 0xff_ff).toString(16)}:0:0`;
|
|
48
|
-
return [`${network}/${bits}`, "0.0.0.0/0"];
|
|
49
|
-
}
|
|
50
|
-
return bits < 96 && range.check("::ffff:0:0", "ipv6") ? [entry, "0.0.0.0/0"] : [entry];
|
|
51
|
-
}
|
|
52
5
|
export function proxyRanges(value: string): string[] | undefined {
|
|
53
|
-
|
|
54
|
-
for (const entry of value.split(",")) {
|
|
55
|
-
const normalized = normalizedProxyRange(entry.trim());
|
|
56
|
-
if (normalized === undefined) {
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
59
|
-
ranges.push(...normalized);
|
|
60
|
-
}
|
|
61
|
-
return ranges;
|
|
6
|
+
return normalizedRanges(value);
|
|
62
7
|
}
|
|
8
|
+
|
|
63
9
|
export function proxyAddresses(value: string): BlockList | undefined {
|
|
64
10
|
const addresses = new BlockList();
|
|
65
11
|
return proxyRanges(value)?.every((entry) => addProxyAddress(addresses, entry)) ? addresses : undefined;
|
|
@@ -6,7 +6,7 @@ import type { EffectTRPCAdapter } from "@shivaedev/effect-trpc/server/adapter.ts
|
|
|
6
6
|
import type { EffectCallerFactory } from "@shivaedev/effect-trpc/server/testing/caller.ts";
|
|
7
7
|
import type { CallerOptions, CallerResult } from "@shivaedev/effect-trpc/server/testing/types.ts";
|
|
8
8
|
import { makeTrpcHarnessIt } from "@shivaedev/effect-trpc/server/testing/vitest.ts";
|
|
9
|
-
import type { Bivariant } from "@shivaedev/types/
|
|
9
|
+
import type { Bivariant } from "@shivaedev/types/Bivariant.ts";
|
|
10
10
|
import type { MakePlatformItOptions, PlatformHarness, PlatformIt } from "./types.ts";
|
|
11
11
|
|
|
12
12
|
interface LooseOptions {
|