@shivaedev/platform 0.3.8 → 0.4.1
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 +25 -1
- package/README.md +41 -2
- package/dist/better-auth/adapter.d.ts +2 -2
- package/dist/better-auth/adapter.d.ts.map +1 -1
- package/dist/better-auth/adapter.js +39 -95
- package/dist/better-auth/adapter.js.map +1 -1
- package/dist/better-auth/relation.d.ts +7 -27
- package/dist/better-auth/relation.d.ts.map +1 -1
- package/dist/better-auth/relation.js +2 -84
- package/dist/better-auth/relation.js.map +1 -1
- package/dist/better-auth/row-adapter.d.ts +8 -0
- package/dist/better-auth/row-adapter.d.ts.map +1 -0
- package/dist/better-auth/row-adapter.js +45 -0
- package/dist/better-auth/row-adapter.js.map +1 -0
- package/dist/better-auth/where.d.ts +22 -0
- package/dist/better-auth/where.d.ts.map +1 -0
- package/dist/better-auth/where.js +93 -0
- package/dist/better-auth/where.js.map +1 -0
- package/dist/better-auth.d.ts +1 -1
- package/dist/errors/rejected-field.d.ts +7 -0
- package/dist/errors/rejected-field.d.ts.map +1 -0
- package/dist/errors/rejected-field.js +5 -0
- package/dist/errors/rejected-field.js.map +1 -0
- package/dist/errors/taxonomy.d.ts +40 -0
- package/dist/errors/taxonomy.d.ts.map +1 -0
- package/dist/errors/taxonomy.js +18 -0
- package/dist/errors/taxonomy.js.map +1 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +1 -0
- package/dist/node-http/subscription-signal.d.ts.map +1 -1
- package/dist/node-http/subscription-signal.js +6 -1
- package/dist/node-http/subscription-signal.js.map +1 -1
- package/dist/node-http.d.ts +1 -1
- package/dist/rpc/identity.d.ts +15 -0
- package/dist/rpc/identity.d.ts.map +1 -0
- package/dist/rpc/identity.js +8 -0
- package/dist/rpc/identity.js.map +1 -0
- package/dist/rpc/middleware.d.ts +15 -0
- package/dist/rpc/middleware.d.ts.map +1 -0
- package/dist/rpc/middleware.js +12 -0
- package/dist/rpc/middleware.js.map +1 -0
- package/dist/rpc-server/adapters/better-auth-sessions.d.ts +4 -0
- package/dist/rpc-server/adapters/better-auth-sessions.d.ts.map +1 -0
- package/dist/rpc-server/adapters/better-auth-sessions.js +11 -0
- package/dist/rpc-server/adapters/better-auth-sessions.js.map +1 -0
- package/dist/rpc-server/origin.d.ts +15 -0
- package/dist/rpc-server/origin.d.ts.map +1 -0
- package/dist/rpc-server/origin.js +12 -0
- package/dist/rpc-server/origin.js.map +1 -0
- package/dist/rpc-server/redact-cause.d.ts +6 -0
- package/dist/rpc-server/redact-cause.d.ts.map +1 -0
- package/dist/rpc-server/redact-cause.js +28 -0
- package/dist/rpc-server/redact-cause.js.map +1 -0
- package/dist/rpc-server/redact.d.ts +3 -0
- package/dist/rpc-server/redact.d.ts.map +1 -0
- package/dist/rpc-server/redact.js +46 -0
- package/dist/rpc-server/redact.js.map +1 -0
- package/dist/rpc-server/sensitive.d.ts +5 -0
- package/dist/rpc-server/sensitive.d.ts.map +1 -0
- package/dist/rpc-server/sensitive.js +49 -0
- package/dist/rpc-server/sensitive.js.map +1 -0
- package/dist/rpc-server/session.d.ts +21 -0
- package/dist/rpc-server/session.d.ts.map +1 -0
- package/dist/rpc-server/session.js +28 -0
- package/dist/rpc-server/session.js.map +1 -0
- package/dist/rpc-server/tracing.d.ts +9 -0
- package/dist/rpc-server/tracing.d.ts.map +1 -0
- package/dist/rpc-server/tracing.js +32 -0
- package/dist/rpc-server/tracing.js.map +1 -0
- package/dist/rpc-server/transport.d.ts +4 -0
- package/dist/rpc-server/transport.d.ts.map +1 -0
- package/dist/rpc-server/transport.js +4 -0
- package/dist/rpc-server/transport.js.map +1 -0
- package/dist/rpc-server.d.ts +9 -0
- package/dist/rpc-server.d.ts.map +1 -0
- package/dist/rpc-server.js +9 -0
- package/dist/rpc-server.js.map +1 -0
- package/dist/rpc.d.ts +3 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +3 -0
- package/dist/rpc.js.map +1 -0
- package/dist/runtime/make.d.ts +1 -1
- package/dist/runtime/make.d.ts.map +1 -1
- package/dist/runtime/make.js +10 -9
- package/dist/runtime/make.js.map +1 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime.d.ts +2 -2
- package/dist/testing/types.d.ts.map +1 -1
- package/dist/testing/vitest.d.ts +1 -1
- package/dist/testing/vitest.d.ts.map +1 -1
- package/dist/testing/vitest.js +7 -9
- package/dist/testing/vitest.js.map +1 -1
- package/dist/testing.d.ts +2 -2
- package/package.json +28 -8
- package/src/better-auth/adapter.ts +50 -190
- package/src/better-auth/relation.ts +21 -163
- package/src/better-auth/row-adapter.ts +78 -0
- package/src/better-auth/where.ts +118 -0
- package/src/better-auth.ts +1 -1
- package/src/errors/rejected-field.ts +13 -0
- package/src/errors/taxonomy.ts +18 -0
- package/src/errors.ts +2 -0
- package/src/node-http/subscription-signal.ts +10 -15
- package/src/node-http.ts +1 -1
- package/src/rpc/identity.ts +11 -0
- package/src/rpc/middleware.ts +15 -0
- package/src/rpc-server/adapters/better-auth-sessions.ts +22 -0
- package/src/rpc-server/origin.ts +29 -0
- package/src/rpc-server/redact-cause.ts +41 -0
- package/src/rpc-server/redact.ts +42 -0
- package/src/rpc-server/sensitive.ts +60 -0
- package/src/rpc-server/session.ts +54 -0
- package/src/rpc-server/tracing.ts +56 -0
- package/src/rpc-server/transport.ts +8 -0
- package/src/rpc-server.ts +15 -0
- package/src/rpc.ts +2 -0
- package/src/runtime/make.ts +20 -27
- package/src/runtime/types.ts +3 -12
- package/src/runtime.ts +2 -2
- package/src/testing/types.ts +7 -32
- package/src/testing/vitest.ts +59 -89
- package/src/testing.ts +2 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const described = { message: Schema.String };
|
|
4
|
+
const perField = { message: Schema.String, field: Schema.optionalKey(Schema.String) };
|
|
5
|
+
|
|
6
|
+
export class NotFound extends Schema.TaggedError<NotFound>()("NotFound", described) {}
|
|
7
|
+
|
|
8
|
+
export class Unauthorized extends Schema.TaggedError<Unauthorized>()("Unauthorized", described) {}
|
|
9
|
+
|
|
10
|
+
export class Forbidden extends Schema.TaggedError<Forbidden>()("Forbidden", described) {}
|
|
11
|
+
|
|
12
|
+
export class BadRequest extends Schema.TaggedError<BadRequest>()("BadRequest", perField) {}
|
|
13
|
+
|
|
14
|
+
export class Conflict extends Schema.TaggedError<Conflict>()("Conflict", perField) {}
|
|
15
|
+
|
|
16
|
+
export class PreconditionFailed extends Schema.TaggedError<PreconditionFailed>()("PreconditionFailed", described) {}
|
|
17
|
+
|
|
18
|
+
export class AuthUnavailable extends Schema.TaggedError<AuthUnavailable>()("AuthUnavailable", described) {}
|
package/src/errors.ts
ADDED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage } from "node:http";
|
|
2
2
|
|
|
3
|
-
interface NodeRequestCarrier {
|
|
4
|
-
readonly runtime?: {
|
|
5
|
-
readonly node?: {
|
|
6
|
-
readonly req?: IncomingMessage;
|
|
7
|
-
};
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
|
|
11
3
|
export interface NodeSubscriptionSignalOptions {
|
|
12
4
|
/** A Web request, including its signal and an optional srvx node runtime. */
|
|
13
5
|
readonly request?: Request | undefined;
|
|
@@ -24,19 +16,22 @@ export interface NodeSubscriptionSignal {
|
|
|
24
16
|
readonly signal: AbortSignal;
|
|
25
17
|
}
|
|
26
18
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
):
|
|
30
|
-
(
|
|
19
|
+
const property = (value: unknown, key: string): unknown => (typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined);
|
|
20
|
+
|
|
21
|
+
const isIncomingMessage = (value: unknown): value is IncomingMessage =>
|
|
22
|
+
typeof property(value, "socket") === "object" && typeof property(value, "once") === "function";
|
|
23
|
+
|
|
24
|
+
const nodeRequestFrom = (request: Request | undefined): IncomingMessage | undefined => {
|
|
25
|
+
const req = property(property(property(request, "runtime"), "node"), "req");
|
|
26
|
+
return isIncomingMessage(req) ? req : undefined;
|
|
27
|
+
};
|
|
31
28
|
|
|
32
29
|
/**
|
|
33
30
|
* Combine Web/procedure abort signals with node request and socket close events.
|
|
34
31
|
* The node events cover long-lived responses under Bun's node:http compatibility
|
|
35
32
|
* layer, where a Web request signal alone may not report an abandoned socket.
|
|
36
33
|
*/
|
|
37
|
-
export const nodeSubscriptionSignal = (
|
|
38
|
-
options: NodeSubscriptionSignalOptions,
|
|
39
|
-
): NodeSubscriptionSignal => {
|
|
34
|
+
export const nodeSubscriptionSignal = (options: NodeSubscriptionSignalOptions): NodeSubscriptionSignal => {
|
|
40
35
|
const controller = new AbortController();
|
|
41
36
|
const signals: AbortSignal[] = [controller.signal];
|
|
42
37
|
if (options.request !== undefined) signals.push(options.request.signal);
|
package/src/node-http.ts
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Context, type Option } from "effect";
|
|
2
|
+
|
|
3
|
+
export class RequestId extends Context.Service<RequestId, string>()("@shivaedev/platform/rpc/RequestId") {}
|
|
4
|
+
|
|
5
|
+
export interface IdentityValue {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class Identity extends Context.Service<Identity, IdentityValue>()("@shivaedev/platform/rpc/Identity") {}
|
|
10
|
+
|
|
11
|
+
export class OptionalIdentity extends Context.Service<OptionalIdentity, Option.Option<IdentityValue>>()("@shivaedev/platform/rpc/OptionalIdentity") {}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { RpcMiddleware } from "effect/unstable/rpc";
|
|
3
|
+
import { AuthUnavailable, Forbidden, Unauthorized } from "../errors/taxonomy.ts";
|
|
4
|
+
import type { Identity, OptionalIdentity, RequestId } from "./identity.ts";
|
|
5
|
+
|
|
6
|
+
export class RequestTracing extends RpcMiddleware.Service<RequestTracing, { provides: RequestId }>()("@shivaedev/platform/rpc/RequestTracing") {}
|
|
7
|
+
|
|
8
|
+
export class Authenticated extends RpcMiddleware.Service<Authenticated, { provides: Identity }>()("@shivaedev/platform/rpc/Authenticated", {
|
|
9
|
+
error: Schema.Union([Unauthorized, AuthUnavailable, Forbidden]),
|
|
10
|
+
}) {}
|
|
11
|
+
|
|
12
|
+
export class MaybeAuthenticated extends RpcMiddleware.Service<MaybeAuthenticated, { provides: OptionalIdentity }>()(
|
|
13
|
+
"@shivaedev/platform/rpc/MaybeAuthenticated",
|
|
14
|
+
{ error: Schema.Union([AuthUnavailable, Forbidden]) },
|
|
15
|
+
) {}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import type { Headers } from "effect/unstable/http";
|
|
3
|
+
import { AuthUnavailable } from "../../errors/taxonomy.ts";
|
|
4
|
+
import { redact } from "../redact.ts";
|
|
5
|
+
import type { SessionProvider, SessionShape } from "../session.ts";
|
|
6
|
+
|
|
7
|
+
export type GetSession<Session extends SessionShape> = (headers: globalThis.Headers) => Promise<Session | null>;
|
|
8
|
+
|
|
9
|
+
const unavailable = (cause: unknown) =>
|
|
10
|
+
Effect.andThen(
|
|
11
|
+
Effect.logError("Authentication provider failed", redact(cause)),
|
|
12
|
+
Effect.fail(new AuthUnavailable({ message: "Authentication is temporarily unavailable" })),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const betterAuthSessions = <Session extends SessionShape>(getSession: GetSession<Session>): SessionProvider<Session> => ({
|
|
16
|
+
get: Effect.fn("PlatformRpc.betterAuthSession")(function* (headers: Headers.Headers) {
|
|
17
|
+
const session = yield* Effect.tryPromise(() => getSession(new globalThis.Headers(headers))).pipe(
|
|
18
|
+
Effect.catch((error) => unavailable(error.cause)),
|
|
19
|
+
);
|
|
20
|
+
return Option.fromNullishOr(session);
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
import { Headers } from "effect/unstable/http";
|
|
3
|
+
|
|
4
|
+
export interface OriginRequest {
|
|
5
|
+
readonly origin: Option.Option<string>;
|
|
6
|
+
readonly rpc: string;
|
|
7
|
+
readonly headers: Headers.Headers;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type OriginPolicy = (request: OriginRequest) => boolean;
|
|
11
|
+
|
|
12
|
+
export interface TrustedOriginsOptions {
|
|
13
|
+
readonly allow: ReadonlyArray<string>;
|
|
14
|
+
readonly missing: "allow" | "reject";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const trustedOrigins =
|
|
18
|
+
(options: TrustedOriginsOptions): OriginPolicy =>
|
|
19
|
+
({ origin }) =>
|
|
20
|
+
Option.match(origin, {
|
|
21
|
+
onNone: () => options.missing === "allow",
|
|
22
|
+
onSome: (value) => options.allow.includes(value),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const originRequest = (headers: Headers.Headers, rpc: string): OriginRequest => ({
|
|
26
|
+
origin: Headers.get(headers, "origin"),
|
|
27
|
+
rpc,
|
|
28
|
+
headers,
|
|
29
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Cause, Context, ErrorReporter, Predicate } from "effect";
|
|
2
|
+
import { redact } from "./redact.ts";
|
|
3
|
+
import { isSensitiveKey, redactText, type SensitiveKey } from "./sensitive.ts";
|
|
4
|
+
|
|
5
|
+
const reporterHints = (error: Error, sensitive: SensitiveKey) => ({
|
|
6
|
+
...(ErrorReporter.isIgnored(error) ? { [ErrorReporter.ignore]: true } : {}),
|
|
7
|
+
...(ErrorReporter.severity in error ? { [ErrorReporter.severity]: ErrorReporter.getSeverity(error) } : {}),
|
|
8
|
+
...(ErrorReporter.attributes in error ? { [ErrorReporter.attributes]: redact(ErrorReporter.getAttributes(error), sensitive) } : {}),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const redactError = (error: Error, sensitive: SensitiveKey): Error => {
|
|
12
|
+
const options = error.cause === undefined ? undefined : { cause: redact(error.cause, sensitive) };
|
|
13
|
+
const copy = new Error(redactText(error.message, sensitive), options);
|
|
14
|
+
Object.defineProperty(copy, "name", { value: error.name, writable: true, configurable: true });
|
|
15
|
+
Object.defineProperty(copy, "stack", {
|
|
16
|
+
value: error.stack === undefined ? undefined : redactText(error.stack, sensitive),
|
|
17
|
+
writable: true,
|
|
18
|
+
configurable: true,
|
|
19
|
+
});
|
|
20
|
+
return Object.assign(copy, redact(Object.fromEntries(Object.entries(error)), sensitive), reporterHints(error, sensitive));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const redactDefect = (defect: unknown, sensitive: SensitiveKey = isSensitiveKey): unknown =>
|
|
24
|
+
Predicate.isError(defect) ? redactError(defect, sensitive) : redact(defect, sensitive);
|
|
25
|
+
|
|
26
|
+
export const redactCause = <E>(cause: Cause.Cause<E>, sensitive: SensitiveKey = isSensitiveKey): Cause.Cause<E> =>
|
|
27
|
+
Cause.fromReasons(
|
|
28
|
+
cause.reasons.flatMap<Cause.Reason<E>>((reason) =>
|
|
29
|
+
Cause.isDieReason(reason)
|
|
30
|
+
? Cause.annotate(Cause.die(redactDefect(reason.defect, sensitive)), Context.makeUnsafe(reason.annotations)).reasons
|
|
31
|
+
: [reason],
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const redactingErrorReporter = (
|
|
36
|
+
reporter: ErrorReporter.ErrorReporter,
|
|
37
|
+
sensitive: SensitiveKey = isSensitiveKey,
|
|
38
|
+
): ErrorReporter.ErrorReporter => ({
|
|
39
|
+
[ErrorReporter.TypeId]: ErrorReporter.TypeId,
|
|
40
|
+
report: (options) => reporter.report({ ...options, cause: redactCause(options.cause, sensitive) }),
|
|
41
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Predicate, Redacted } from "effect";
|
|
2
|
+
import { isSensitiveKey, REDACTED, redactText, type SensitiveKey } from "./sensitive.ts";
|
|
3
|
+
|
|
4
|
+
const MAX_DEPTH = 8;
|
|
5
|
+
const MAX_ENTRIES = 50;
|
|
6
|
+
|
|
7
|
+
const binary = (value: object): string | undefined => {
|
|
8
|
+
if (ArrayBuffer.isView(value)) return `<${value.constructor.name} ${value.byteLength} bytes>`;
|
|
9
|
+
if (value instanceof ArrayBuffer) return `<ArrayBuffer ${value.byteLength} bytes>`;
|
|
10
|
+
return undefined;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const entriesOf = (value: object): Array<[string, unknown]> => {
|
|
14
|
+
const entries = Object.entries(value);
|
|
15
|
+
if (!Predicate.isError(value)) return entries;
|
|
16
|
+
const shape: Array<[string, unknown]> = [
|
|
17
|
+
["name", value.name],
|
|
18
|
+
["message", value.message],
|
|
19
|
+
["stack", value.stack],
|
|
20
|
+
];
|
|
21
|
+
return value.cause === undefined ? [...shape, ...entries] : [...shape, ...entries, ["cause", value.cause]];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const walk = (value: unknown, sensitive: SensitiveKey, depth: number): unknown => {
|
|
25
|
+
if (Redacted.isRedacted(value)) return REDACTED;
|
|
26
|
+
if (Predicate.isString(value)) return redactText(value, sensitive);
|
|
27
|
+
if (!Predicate.isObjectOrArray(value)) return value;
|
|
28
|
+
const summary = binary(value);
|
|
29
|
+
if (summary !== undefined) return summary;
|
|
30
|
+
if (depth >= MAX_DEPTH) return "<truncated>";
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
const items = value.slice(0, MAX_ENTRIES).map((item) => walk(item, sensitive, depth + 1));
|
|
33
|
+
return value.length > MAX_ENTRIES ? [...items, `<${value.length - MAX_ENTRIES} more items>`] : items;
|
|
34
|
+
}
|
|
35
|
+
const entries = entriesOf(value);
|
|
36
|
+
const kept = entries
|
|
37
|
+
.slice(0, MAX_ENTRIES)
|
|
38
|
+
.map(([key, nested]): [string, unknown] => [key, sensitive(key) ? REDACTED : walk(nested, sensitive, depth + 1)]);
|
|
39
|
+
return Object.fromEntries(entries.length > MAX_ENTRIES ? [...kept, ["<truncated>", `${entries.length - MAX_ENTRIES} more keys`]] : kept);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const redact = (value: unknown, sensitive: SensitiveKey = isSensitiveKey): unknown => walk(value, sensitive, 0);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const REDACTED = "<redacted>";
|
|
2
|
+
|
|
3
|
+
const MAX_STRING = 2048;
|
|
4
|
+
const SENSITIVE_PARTS = [
|
|
5
|
+
"password",
|
|
6
|
+
"passphrase",
|
|
7
|
+
"secret",
|
|
8
|
+
"token",
|
|
9
|
+
"authorization",
|
|
10
|
+
"cookie",
|
|
11
|
+
"apikey",
|
|
12
|
+
"accesskey",
|
|
13
|
+
"credential",
|
|
14
|
+
"privatekey",
|
|
15
|
+
"jwt",
|
|
16
|
+
"bearer",
|
|
17
|
+
"signature",
|
|
18
|
+
"sessionid",
|
|
19
|
+
"cardnumber",
|
|
20
|
+
"accountnumber",
|
|
21
|
+
"csrf",
|
|
22
|
+
"xsrf",
|
|
23
|
+
];
|
|
24
|
+
const SENSITIVE_WORDS = ["otp", "pin", "cvv", "cvc", "ssn", "iban"];
|
|
25
|
+
const SENSITIVE_NAMES = ["session", "pan"];
|
|
26
|
+
|
|
27
|
+
export type SensitiveKey = (key: string) => boolean;
|
|
28
|
+
|
|
29
|
+
const wordsOf = (key: string): Array<string> =>
|
|
30
|
+
key
|
|
31
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
32
|
+
.replaceAll(/([A-Z])([A-Z][a-z])/g, "$1 $2")
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.split(/[\s_-]+/);
|
|
35
|
+
|
|
36
|
+
export const isSensitiveKey: SensitiveKey = (key) => {
|
|
37
|
+
const normalized = key.toLowerCase().replaceAll(/[-_]/g, "");
|
|
38
|
+
return (
|
|
39
|
+
SENSITIVE_NAMES.includes(normalized) ||
|
|
40
|
+
SENSITIVE_PARTS.some((part) => normalized.includes(part)) ||
|
|
41
|
+
wordsOf(key).some((word) => SENSITIVE_WORDS.includes(word))
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const AUTH_SCHEME = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi;
|
|
46
|
+
const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g;
|
|
47
|
+
const URL_PASSWORD = /(\/\/[^:/\s@]+:)[^@/\s]+@/g;
|
|
48
|
+
const ASSIGNMENT = /([A-Za-z][\w.-]*)(["']?\s*[=:]\s*["']?)([^\s"'&,;]+)/g;
|
|
49
|
+
|
|
50
|
+
const scrub = (text: string, sensitive: SensitiveKey): string =>
|
|
51
|
+
text
|
|
52
|
+
.replaceAll(AUTH_SCHEME, `$1 ${REDACTED}`)
|
|
53
|
+
.replaceAll(JWT, REDACTED)
|
|
54
|
+
.replaceAll(URL_PASSWORD, `$1${REDACTED}@`)
|
|
55
|
+
.replaceAll(ASSIGNMENT, (match: string, key: string, separator: string) => (sensitive(key) ? `${key}${separator}${REDACTED}` : match));
|
|
56
|
+
|
|
57
|
+
export const redactText = (text: string, sensitive: SensitiveKey): string => {
|
|
58
|
+
const scrubbed = scrub(text, sensitive);
|
|
59
|
+
return scrubbed.length > MAX_STRING ? `${scrubbed.slice(0, MAX_STRING)}…<${scrubbed.length - MAX_STRING} more chars>` : scrubbed;
|
|
60
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Layer, Option } from "effect";
|
|
2
|
+
import type { Headers } from "effect/unstable/http";
|
|
3
|
+
import { type AuthUnavailable, Forbidden, Unauthorized } from "../errors/taxonomy.ts";
|
|
4
|
+
import { Identity, OptionalIdentity } from "../rpc/identity.ts";
|
|
5
|
+
import { Authenticated, MaybeAuthenticated } from "../rpc/middleware.ts";
|
|
6
|
+
import { type OriginPolicy, originRequest } from "./origin.ts";
|
|
7
|
+
import { transportHeaders } from "./transport.ts";
|
|
8
|
+
|
|
9
|
+
export interface SessionShape {
|
|
10
|
+
readonly user: { readonly id: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SessionProvider<Session extends SessionShape> {
|
|
14
|
+
readonly get: (headers: Headers.Headers) => Effect.Effect<Option.Option<Session>, AuthUnavailable>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SessionPolicy<Session extends SessionShape> {
|
|
18
|
+
readonly provider: SessionProvider<Session>;
|
|
19
|
+
readonly origin: OriginPolicy;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const resolveSession: <Session extends SessionShape>(
|
|
23
|
+
policy: SessionPolicy<Session>,
|
|
24
|
+
headers: Headers.Headers,
|
|
25
|
+
rpc: string,
|
|
26
|
+
) => Effect.Effect<Option.Option<Session>, AuthUnavailable | Forbidden> = Effect.fn("PlatformRpc.resolveSession")(function* <
|
|
27
|
+
Session extends SessionShape,
|
|
28
|
+
>(policy: SessionPolicy<Session>, headers: Headers.Headers, rpc: string) {
|
|
29
|
+
const transport = yield* transportHeaders(headers);
|
|
30
|
+
if (!policy.origin(originRequest(transport, rpc))) return yield* new Forbidden({ message: "Origin not allowed" });
|
|
31
|
+
return yield* policy.provider.get(transport);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const identified = (id: string) => Effect.andThen(Effect.annotateCurrentSpan("user.id", id), Effect.succeed(id));
|
|
35
|
+
|
|
36
|
+
export const authenticatedLayer = <Session extends SessionShape>(policy: SessionPolicy<Session>): Layer.Layer<Authenticated> =>
|
|
37
|
+
Layer.succeed(Authenticated, (effect, { headers, rpc }) =>
|
|
38
|
+
Effect.gen(function* () {
|
|
39
|
+
const session = yield* resolveSession(policy, headers, rpc._tag);
|
|
40
|
+
if (Option.isNone(session)) return yield* new Unauthorized({ message: "Authentication required" });
|
|
41
|
+
const id = yield* identified(session.value.user.id);
|
|
42
|
+
return yield* effect.pipe(Effect.provideService(Identity, { id }), Effect.annotateLogs({ userId: id }));
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export const maybeAuthenticatedLayer = <Session extends SessionShape>(policy: SessionPolicy<Session>): Layer.Layer<MaybeAuthenticated> =>
|
|
47
|
+
Layer.succeed(MaybeAuthenticated, (effect, { headers, rpc }) =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
const session = yield* resolveSession(policy, headers, rpc._tag);
|
|
50
|
+
if (Option.isNone(session)) return yield* Effect.provideService(effect, OptionalIdentity, Option.none());
|
|
51
|
+
const id = yield* identified(session.value.user.id);
|
|
52
|
+
return yield* effect.pipe(Effect.provideService(OptionalIdentity, Option.some({ id })), Effect.annotateLogs({ userId: id }));
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Cause, Effect, Layer, Option, Predicate, Random } from "effect";
|
|
2
|
+
import { Headers } from "effect/unstable/http";
|
|
3
|
+
import { RequestId } from "../rpc/identity.ts";
|
|
4
|
+
import { RequestTracing } from "../rpc/middleware.ts";
|
|
5
|
+
import { redact } from "./redact.ts";
|
|
6
|
+
import { redactCause } from "./redact-cause.ts";
|
|
7
|
+
import type { SensitiveKey } from "./sensitive.ts";
|
|
8
|
+
|
|
9
|
+
export interface RequestTracingOptions {
|
|
10
|
+
readonly header?: string;
|
|
11
|
+
readonly sensitive?: SensitiveKey;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ACCEPTED_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
15
|
+
|
|
16
|
+
const hex = (byte: number): string => 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
|
+
|
|
20
|
+
const requestIdOf = (headers: Headers.Headers, header: string): Effect.Effect<string> =>
|
|
21
|
+
Option.match(
|
|
22
|
+
Option.filter(Headers.get(headers, header), (value) => ACCEPTED_REQUEST_ID.test(value)),
|
|
23
|
+
{
|
|
24
|
+
onNone: () => generateRequestId,
|
|
25
|
+
onSome: Effect.succeed,
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const failureTag = (cause: Cause.Cause<unknown>): string =>
|
|
30
|
+
Option.match(Cause.findErrorOption(cause), {
|
|
31
|
+
onNone: () => "unknown",
|
|
32
|
+
onSome: (error) => (Predicate.hasProperty(error, "_tag") && Predicate.isString(error._tag) ? error._tag : "unknown"),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const logFailure = (cause: Cause.Cause<unknown>, payload: unknown, sensitive: SensitiveKey | undefined): Effect.Effect<void> => {
|
|
36
|
+
if (Cause.hasInterruptsOnly(cause)) return Effect.void;
|
|
37
|
+
const annotations = { "rpc.payload": redact(payload, sensitive) };
|
|
38
|
+
if (!Cause.hasDies(cause)) return Effect.annotateLogs(Effect.logInfo("RPC failure"), { ...annotations, "rpc.failure": failureTag(cause) });
|
|
39
|
+
const defects = cause.reasons.filter(Cause.isDieReason).map((reason) => redact(reason.defect, sensitive));
|
|
40
|
+
return Effect.annotateLogs(Effect.logError("RPC defect"), { ...annotations, "rpc.defect": defects });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const requestTracingLayer = (options: RequestTracingOptions = {}): Layer.Layer<RequestTracing> =>
|
|
44
|
+
Layer.succeed(RequestTracing, (effect, { headers, payload, rpc }) =>
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
const requestId = yield* requestIdOf(headers, options.header ?? "x-request-id");
|
|
47
|
+
yield* Effect.annotateCurrentSpan({ "request.id": requestId, "rpc.method": rpc._tag });
|
|
48
|
+
return yield* effect.pipe(
|
|
49
|
+
Effect.catchCauseIf(Cause.hasDies, (cause) => Effect.failCause(redactCause(cause, options.sensitive))),
|
|
50
|
+
Effect.tapCause((cause) => logFailure(cause, payload, options.sensitive)),
|
|
51
|
+
Effect.provideService(RequestId, requestId),
|
|
52
|
+
Effect.annotateLogs({ requestId, "rpc.method": rpc._tag }),
|
|
53
|
+
Effect.withLogSpan("rpc.duration"),
|
|
54
|
+
);
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { type Headers, HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
|
|
4
|
+
export const transportHeaders = (rpcHeaders: Headers.Headers): Effect.Effect<Headers.Headers> =>
|
|
5
|
+
Effect.map(
|
|
6
|
+
Effect.serviceOption(HttpServerRequest.HttpServerRequest),
|
|
7
|
+
Option.match({ onNone: () => rpcHeaders, onSome: (request) => request.headers }),
|
|
8
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { betterAuthSessions, type GetSession } from "./rpc-server/adapters/better-auth-sessions.ts";
|
|
2
|
+
export { type OriginPolicy, type OriginRequest, type TrustedOriginsOptions, trustedOrigins } from "./rpc-server/origin.ts";
|
|
3
|
+
export { redact } from "./rpc-server/redact.ts";
|
|
4
|
+
export { redactCause, redactDefect, redactingErrorReporter } from "./rpc-server/redact-cause.ts";
|
|
5
|
+
export { isSensitiveKey, type SensitiveKey } from "./rpc-server/sensitive.ts";
|
|
6
|
+
export {
|
|
7
|
+
authenticatedLayer,
|
|
8
|
+
maybeAuthenticatedLayer,
|
|
9
|
+
resolveSession,
|
|
10
|
+
type SessionPolicy,
|
|
11
|
+
type SessionProvider,
|
|
12
|
+
type SessionShape,
|
|
13
|
+
} from "./rpc-server/session.ts";
|
|
14
|
+
export { type RequestTracingOptions, requestTracingLayer } from "./rpc-server/tracing.ts";
|
|
15
|
+
export { transportHeaders } from "./rpc-server/transport.ts";
|
package/src/rpc.ts
ADDED
package/src/runtime/make.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import type { Layer } from "effect";
|
|
3
|
-
import { Cause, Context, Effect, Exit, ManagedRuntime } from "effect";
|
|
4
|
-
import type { PlatformRuntime, PlatformRuntimeOptions } from "./types.
|
|
3
|
+
import { Cause, Config, Context, Effect, Exit, ManagedRuntime } from "effect";
|
|
4
|
+
import type { PlatformRuntime, PlatformRuntimeOptions } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
const RuntimeCacheKey = Symbol.for("@shivaedev/platform/runtime-cache");
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const runtimeCache =
|
|
11
|
-
globalRuntimeCache[RuntimeCacheKey] ??
|
|
12
|
-
new Map<string | symbol, PlatformRuntime<unknown, unknown>>();
|
|
13
|
-
globalRuntimeCache[RuntimeCacheKey] = runtimeCache;
|
|
7
|
+
const sharedCache: unknown = Reflect.get(globalThis, RuntimeCacheKey);
|
|
8
|
+
const runtimeCache: Map<string | symbol, unknown> = sharedCache instanceof Map ? sharedCache : new Map<string | symbol, unknown>();
|
|
9
|
+
Reflect.set(globalThis, RuntimeCacheKey, runtimeCache);
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
):
|
|
11
|
+
// One development cache key belongs to one makePlatformRuntime call site, so its entry has that site's services and build error.
|
|
12
|
+
function cachedEntry<Services, BuildError>(runtime: unknown): PlatformRuntime<Services, BuildError>;
|
|
13
|
+
function cachedEntry(runtime: unknown): unknown {
|
|
14
|
+
return runtime;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const make = <Services, BuildError>(layer: Layer.Layer<Services, BuildError>): PlatformRuntime<Services, BuildError> => {
|
|
18
18
|
const managed = ManagedRuntime.make(layer);
|
|
19
19
|
const services = new AsyncLocalStorage<Context.Context<never>>();
|
|
20
20
|
|
|
@@ -25,15 +25,10 @@ const make = <Services, BuildError>(
|
|
|
25
25
|
const ambient = currentServices();
|
|
26
26
|
return ambient === undefined
|
|
27
27
|
? effect
|
|
28
|
-
: Effect.flatMap(managed.contextEffect, (base) =>
|
|
29
|
-
Effect.provideContext(effect, Context.merge(base, ambient)),
|
|
30
|
-
);
|
|
28
|
+
: Effect.flatMap(managed.contextEffect, (base) => Effect.provideContext(effect, Context.merge(base, ambient)));
|
|
31
29
|
};
|
|
32
30
|
|
|
33
|
-
const runPromiseExit: PlatformRuntime<
|
|
34
|
-
Services,
|
|
35
|
-
BuildError
|
|
36
|
-
>["runPromiseExit"] = (effect, options) =>
|
|
31
|
+
const runPromiseExit: PlatformRuntime<Services, BuildError>["runPromiseExit"] = (effect, options) =>
|
|
37
32
|
managed.runPromiseExit(withAmbient(effect), options);
|
|
38
33
|
|
|
39
34
|
const runtime: PlatformRuntime<Services, BuildError> = {
|
|
@@ -49,26 +44,24 @@ const make = <Services, BuildError>(
|
|
|
49
44
|
throw Cause.squash(exit.cause);
|
|
50
45
|
},
|
|
51
46
|
runPromiseExit,
|
|
52
|
-
runWithServices: (context, evaluate) =>
|
|
53
|
-
services.run(context as Context.Context<never>, evaluate),
|
|
47
|
+
runWithServices: (context, evaluate) => services.run(context, evaluate),
|
|
54
48
|
};
|
|
55
49
|
|
|
56
50
|
return runtime;
|
|
57
51
|
};
|
|
58
52
|
|
|
53
|
+
const production = Effect.map(Effect.orDie(Config.string("NODE_ENV").pipe(Config.withDefault(""))), (environment) => environment === "production");
|
|
54
|
+
|
|
59
55
|
export const makePlatformRuntime = <Services, BuildError>(
|
|
60
56
|
layer: Layer.Layer<Services, BuildError>,
|
|
61
57
|
options: PlatformRuntimeOptions = {},
|
|
62
58
|
): PlatformRuntime<Services, BuildError> => {
|
|
63
|
-
const key =
|
|
64
|
-
process.env.NODE_ENV === "production"
|
|
65
|
-
? undefined
|
|
66
|
-
: options.developmentCacheKey;
|
|
59
|
+
const key = Effect.runSync(production) ? undefined : options.developmentCacheKey;
|
|
67
60
|
if (key === undefined) return make(layer);
|
|
68
61
|
|
|
69
62
|
const cached = runtimeCache.get(key);
|
|
70
63
|
if (cached !== undefined) {
|
|
71
|
-
return
|
|
64
|
+
return cachedEntry<Services, BuildError>(cached);
|
|
72
65
|
}
|
|
73
66
|
|
|
74
67
|
const runtime = make(layer);
|
|
@@ -79,6 +72,6 @@ export const makePlatformRuntime = <Services, BuildError>(
|
|
|
79
72
|
await runtime.dispose();
|
|
80
73
|
},
|
|
81
74
|
};
|
|
82
|
-
runtimeCache.set(key, cachedRuntime
|
|
75
|
+
runtimeCache.set(key, cachedRuntime);
|
|
83
76
|
return cachedRuntime;
|
|
84
77
|
};
|
package/src/runtime/types.ts
CHANGED
|
@@ -17,18 +17,9 @@ export interface PlatformRuntime<Services, BuildError> {
|
|
|
17
17
|
effect: Effect.Effect<A, E, Requirements>,
|
|
18
18
|
options?: { readonly signal?: AbortSignal },
|
|
19
19
|
) => Promise<Exit.Exit<A, E | BuildError>>;
|
|
20
|
-
readonly runWithServices: <Provided, Value>(
|
|
21
|
-
services: Context.Context<Provided>,
|
|
22
|
-
evaluate: () => Value,
|
|
23
|
-
) => Value;
|
|
20
|
+
readonly runWithServices: <Provided, Value>(services: Context.Context<Provided>, evaluate: () => Value) => Value;
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
export type PlatformRuntimeServices<Runtime> =
|
|
27
|
-
Runtime extends PlatformRuntime<infer Services, infer _BuildError>
|
|
28
|
-
? Services
|
|
29
|
-
: never;
|
|
23
|
+
export type PlatformRuntimeServices<Runtime> = Runtime extends PlatformRuntime<infer Services, infer _BuildError> ? Services : never;
|
|
30
24
|
|
|
31
|
-
export type PlatformRuntimeBuildError<Runtime> =
|
|
32
|
-
Runtime extends PlatformRuntime<infer _Services, infer BuildError>
|
|
33
|
-
? BuildError
|
|
34
|
-
: never;
|
|
25
|
+
export type PlatformRuntimeBuildError<Runtime> = Runtime extends PlatformRuntime<infer _Services, infer BuildError> ? BuildError : never;
|
package/src/runtime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export { makePlatformRuntime } from "./runtime/make.
|
|
1
|
+
export { makePlatformRuntime } from "./runtime/make.ts";
|
|
2
2
|
export type {
|
|
3
3
|
PlatformRuntime,
|
|
4
4
|
PlatformRuntimeBuildError,
|
|
5
5
|
PlatformRuntimeOptions,
|
|
6
6
|
PlatformRuntimeServices,
|
|
7
|
-
} from "./runtime/types.
|
|
7
|
+
} from "./runtime/types.ts";
|
package/src/testing/types.ts
CHANGED
|
@@ -1,43 +1,21 @@
|
|
|
1
1
|
import type { TestContext, Vitest } from "@effect/vitest";
|
|
2
|
-
import type {
|
|
3
|
-
AnyDatabase,
|
|
4
|
-
DatabaseService,
|
|
5
|
-
} from "@shivaedev/effect-prisma/testing";
|
|
2
|
+
import type { AnyDatabase, DatabaseService } from "@shivaedev/effect-prisma/testing";
|
|
6
3
|
import type { EffectTRPCAdapter } from "@shivaedev/effect-trpc";
|
|
7
|
-
import type {
|
|
8
|
-
CallerOptions,
|
|
9
|
-
CallerResult,
|
|
10
|
-
EffectCallerFactory,
|
|
11
|
-
TrpcHarnessTest,
|
|
12
|
-
TrpcHarnessTester,
|
|
13
|
-
} from "@shivaedev/effect-trpc/testing";
|
|
4
|
+
import type { CallerOptions, CallerResult, EffectCallerFactory, TrpcHarnessTest, TrpcHarnessTester } from "@shivaedev/effect-trpc/testing";
|
|
14
5
|
import type { Effect, Layer } from "effect";
|
|
15
6
|
|
|
16
|
-
export type PlatformHarness<
|
|
17
|
-
Database extends AnyDatabase,
|
|
18
|
-
Options,
|
|
19
|
-
Caller,
|
|
20
|
-
Extension,
|
|
21
|
-
> = Readonly<{
|
|
7
|
+
export type PlatformHarness<Database extends AnyDatabase, Options, Caller, Extension> = Readonly<{
|
|
22
8
|
db: DatabaseService<Database>;
|
|
23
9
|
trpc: EffectCallerFactory<Options, Caller>;
|
|
24
10
|
}> &
|
|
25
11
|
Readonly<{
|
|
26
|
-
promise: <Value>(
|
|
27
|
-
evaluate: () => Promise<Value>,
|
|
28
|
-
) => Effect.Effect<Value, unknown>;
|
|
12
|
+
promise: <Value>(evaluate: () => Promise<Value>) => Effect.Effect<Value, unknown>;
|
|
29
13
|
}> &
|
|
30
14
|
Omit<Extension, "db" | "promise" | "trpc">;
|
|
31
15
|
|
|
32
|
-
export type PlatformTest<Harness, Provided> = TrpcHarnessTest<
|
|
33
|
-
Harness,
|
|
34
|
-
Provided
|
|
35
|
-
>;
|
|
16
|
+
export type PlatformTest<Harness, Provided> = TrpcHarnessTest<Harness, Provided>;
|
|
36
17
|
|
|
37
|
-
export type PlatformTester<Harness, Provided> = TrpcHarnessTester<
|
|
38
|
-
Harness,
|
|
39
|
-
Provided
|
|
40
|
-
>;
|
|
18
|
+
export type PlatformTester<Harness, Provided> = TrpcHarnessTester<Harness, Provided>;
|
|
41
19
|
|
|
42
20
|
export type PlatformIt<Harness, Provided> = Vitest.Methods & {
|
|
43
21
|
readonly effectApp: PlatformTester<Harness, Provided>;
|
|
@@ -55,10 +33,7 @@ export interface MakePlatformItOptions<
|
|
|
55
33
|
readonly extend?: (
|
|
56
34
|
base: Readonly<{
|
|
57
35
|
db: DatabaseService<Database>;
|
|
58
|
-
trpc: EffectCallerFactory<
|
|
59
|
-
CallerOptions<NoInfer<CreateCaller>>,
|
|
60
|
-
CallerResult<NoInfer<CreateCaller>>
|
|
61
|
-
>;
|
|
36
|
+
trpc: EffectCallerFactory<CallerOptions<NoInfer<CreateCaller>>, CallerResult<NoInfer<CreateCaller>>>;
|
|
62
37
|
}>,
|
|
63
38
|
context: TestContext,
|
|
64
39
|
) => Effect.Effect<Extension, unknown, Provided | Effect.Services<Database>>;
|