@shivaedev/platform 0.18.2 → 0.20.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 +39 -0
- package/README.md +30 -9
- package/dist/server/RedactionPolicy.d.ts +4 -0
- package/dist/server/RedactionPolicy.d.ts.map +1 -0
- package/dist/server/RedactionPolicy.js +4 -0
- package/dist/server/RedactionPolicy.js.map +1 -0
- package/dist/server/appHost.js +3 -3
- package/dist/server/appHost.js.map +1 -1
- package/dist/server/appHostHttp.d.ts +4 -4
- package/dist/server/appHostHttp.d.ts.map +1 -1
- package/dist/server/appHostHttp.js +17 -11
- package/dist/server/appHostHttp.js.map +1 -1
- package/dist/server/commandChanges.d.ts +4 -0
- package/dist/server/commandChanges.d.ts.map +1 -0
- package/dist/server/commandChanges.js +39 -0
- package/dist/server/commandChanges.js.map +1 -0
- package/dist/server/databaseLayer.d.ts +2 -2
- package/dist/server/databaseLayer.d.ts.map +1 -1
- package/dist/server/databaseLayer.js +6 -5
- package/dist/server/databaseLayer.js.map +1 -1
- package/dist/server/hostChanges.d.ts +5 -0
- package/dist/server/hostChanges.d.ts.map +1 -0
- package/dist/server/hostChanges.js +23 -0
- package/dist/server/hostChanges.js.map +1 -0
- package/dist/server/hostOptions.d.ts +1 -0
- package/dist/server/hostOptions.d.ts.map +1 -1
- package/dist/server/otlpHttpClient.d.ts.map +1 -1
- package/dist/server/otlpHttpClient.js +3 -1
- package/dist/server/otlpHttpClient.js.map +1 -1
- package/dist/server/otlpTracing.d.ts.map +1 -1
- package/dist/server/otlpTracing.js +4 -1
- package/dist/server/otlpTracing.js.map +1 -1
- package/dist/server/redactingDiagnostics.d.ts +5 -0
- package/dist/server/redactingDiagnostics.d.ts.map +1 -0
- package/dist/server/redactingDiagnostics.js +25 -0
- package/dist/server/redactingDiagnostics.js.map +1 -0
- package/dist/server/redactingRpcSerialization.d.ts +4 -0
- package/dist/server/redactingRpcSerialization.d.ts.map +1 -0
- package/dist/server/redactingRpcSerialization.js +74 -0
- package/dist/server/redactingRpcSerialization.js.map +1 -0
- package/dist/server/redactingTracer.d.ts +4 -0
- package/dist/server/redactingTracer.d.ts.map +1 -0
- package/dist/server/redactingTracer.js +32 -0
- package/dist/server/redactingTracer.js.map +1 -0
- package/dist/server/rpc-server/redact.d.ts +2 -1
- package/dist/server/rpc-server/redact.d.ts.map +1 -1
- package/dist/server/rpc-server/redact.js +26 -18
- package/dist/server/rpc-server/redact.js.map +1 -1
- package/dist/server/rpc-server/redactCause.d.ts +2 -1
- package/dist/server/rpc-server/redactCause.d.ts.map +1 -1
- package/dist/server/rpc-server/redactCause.js +64 -8
- package/dist/server/rpc-server/redactCause.js.map +1 -1
- package/dist/server/rpc-server/sensitive.d.ts +2 -1
- package/dist/server/rpc-server/sensitive.d.ts.map +1 -1
- package/dist/server/rpc-server/sensitive.js +8 -2
- package/dist/server/rpc-server/sensitive.js.map +1 -1
- package/dist/server/rpc-server/tracing.d.ts +1 -1
- package/dist/server/rpc-server/tracing.d.ts.map +1 -1
- package/dist/server/rpc-server/tracing.js +11 -4
- package/dist/server/rpc-server/tracing.js.map +1 -1
- package/dist/server/safeOtlpSerialization.d.ts +2 -1
- package/dist/server/safeOtlpSerialization.d.ts.map +1 -1
- package/dist/server/safeOtlpSerialization.js +32 -21
- package/dist/server/safeOtlpSerialization.js.map +1 -1
- package/dist/server/schemaSensitiveKeys.d.ts +5 -0
- package/dist/server/schemaSensitiveKeys.d.ts.map +1 -0
- package/dist/server/schemaSensitiveKeys.js +143 -0
- package/dist/server/schemaSensitiveKeys.js.map +1 -0
- package/package.json +6 -6
- package/src/server/RedactionPolicy.ts +4 -0
- package/src/server/appHost.ts +3 -3
- package/src/server/appHostHttp.ts +23 -9
- package/src/server/commandChanges.ts +46 -0
- package/src/server/databaseLayer.ts +7 -6
- package/src/server/hostChanges.ts +27 -0
- package/src/server/hostOptions.ts +1 -0
- package/src/server/otlpHttpClient.ts +3 -1
- package/src/server/otlpTracing.ts +4 -1
- package/src/server/redactingDiagnostics.ts +33 -0
- package/src/server/redactingRpcSerialization.ts +85 -0
- package/src/server/redactingTracer.ts +37 -0
- package/src/server/rpc-server/redact.ts +26 -18
- package/src/server/rpc-server/redactCause.ts +73 -9
- package/src/server/rpc-server/sensitive.ts +8 -2
- package/src/server/rpc-server/tracing.ts +11 -5
- package/src/server/safeOtlpSerialization.ts +44 -34
- package/src/server/schemaSensitiveKeys.ts +148 -0
|
@@ -4,17 +4,22 @@ import { ByteSize, Config, Effect, Layer, Option, Schema } from "effect";
|
|
|
4
4
|
import { HttpIncomingMessage, HttpRouter, HttpServerRequest, HttpStaticServer } from "effect/http";
|
|
5
5
|
import { type Rpc, RpcSerialization, RpcServer } from "effect/rpc";
|
|
6
6
|
import type { Authenticated, MaybeAuthenticated, RequestTracing } from "#rpc/middleware.ts";
|
|
7
|
-
import type { AppHttpHostOptions } from "#server/appHost.ts";
|
|
7
|
+
import type { AppChanges, AppHttpHostOptions } from "#server/appHost.ts";
|
|
8
8
|
import { boundedRpcRequest } from "#server/boundedRpcRequest.ts";
|
|
9
9
|
import type { databaseLayer } from "#server/databaseLayer.ts";
|
|
10
10
|
import { healthRoute } from "#server/healthRoute.ts";
|
|
11
|
+
import { hostChanges } from "#server/hostChanges.ts";
|
|
11
12
|
import type { HostOptions, HostPolicy } from "#server/hostOptions.ts";
|
|
12
13
|
import type { MigrationOptions } from "#server/MigrationOptions.ts";
|
|
13
14
|
import { otlpTracing } from "#server/otlpTracing.ts";
|
|
15
|
+
import { RedactionPolicy } from "#server/RedactionPolicy.ts";
|
|
16
|
+
import { redactingDiagnostics } from "#server/redactingDiagnostics.ts";
|
|
17
|
+
import { redactingRpcSerialization } from "#server/redactingRpcSerialization.ts";
|
|
14
18
|
import { localRpcGuard } from "#server/rpc-server/local.ts";
|
|
15
19
|
import { authenticatedLayer, maybeAuthenticatedLayer, type SessionShape } from "#server/rpc-server/session.ts";
|
|
16
20
|
import { requestTracingLayer } from "#server/rpc-server/tracing.ts";
|
|
17
21
|
import { TrustedProxy } from "#server/rpc-server/trustedProxy.ts";
|
|
22
|
+
import { schemaSensitiveKeys } from "#server/schemaSensitiveKeys.ts";
|
|
18
23
|
import { tracedGroup } from "#server/tracedGroup.ts";
|
|
19
24
|
export function appHostHttp<
|
|
20
25
|
TRpcs extends Rpc.Any,
|
|
@@ -27,7 +32,9 @@ export function appHostHttp<
|
|
|
27
32
|
TSession extends SessionShape,
|
|
28
33
|
>(
|
|
29
34
|
options: HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> & HostPolicy<TSession>,
|
|
35
|
+
changesService: typeof AppChanges,
|
|
30
36
|
) {
|
|
37
|
+
const sensitive = schemaSensitiveKeys(options.group, options.config?.sensitiveKeys);
|
|
31
38
|
return Layer.unwrap(
|
|
32
39
|
Effect.gen(function* () {
|
|
33
40
|
const maxBodySize = options.config?.maxBodySize ?? (yield* Config.Int("MAX_BODY_SIZE").pipe(Config.withDefault(1024 * 1024)));
|
|
@@ -43,12 +50,14 @@ export function appHostHttp<
|
|
|
43
50
|
}
|
|
44
51
|
const middleware: Layer.Layer<RequestTracing> | Layer.Layer<Authenticated | MaybeAuthenticated | RequestTracing> =
|
|
45
52
|
options.policy === "loopback"
|
|
46
|
-
? requestTracingLayer()
|
|
47
|
-
: Layer.mergeAll(authenticatedLayer(options.sessions), maybeAuthenticatedLayer(options.sessions), requestTracingLayer());
|
|
53
|
+
? requestTracingLayer({ sensitive })
|
|
54
|
+
: Layer.mergeAll(authenticatedLayer(options.sessions), maybeAuthenticatedLayer(options.sessions), requestTracingLayer({ sensitive }));
|
|
48
55
|
const routes = HttpRouter.use((router) =>
|
|
49
56
|
Effect.gen(function* () {
|
|
50
|
-
const wire = yield* RpcSerialization.RpcSerialization;
|
|
51
|
-
const handler = yield* RpcServer.toHttpEffect(tracedGroup(options.group))
|
|
57
|
+
const wire = redactingRpcSerialization(yield* RpcSerialization.RpcSerialization, sensitive, sensitive.maskFailure);
|
|
58
|
+
const handler = yield* RpcServer.toHttpEffect(tracedGroup(options.group)).pipe(
|
|
59
|
+
Effect.provideService(RpcSerialization.RpcSerialization, wire),
|
|
60
|
+
);
|
|
52
61
|
const guarded = localRpcGuard(handler, trustedProxy);
|
|
53
62
|
yield* router.add("POST", options.rpcPath ?? "/rpc", (request) =>
|
|
54
63
|
guarded.pipe(
|
|
@@ -58,7 +67,10 @@ export function appHostHttp<
|
|
|
58
67
|
),
|
|
59
68
|
);
|
|
60
69
|
}),
|
|
61
|
-
).pipe(
|
|
70
|
+
).pipe(
|
|
71
|
+
Layer.provide(Layer.mergeAll(options.handlers, middleware, hostChanges(options.group, changesService))),
|
|
72
|
+
Layer.provide(RpcSerialization.layerNdjson),
|
|
73
|
+
);
|
|
62
74
|
const assets =
|
|
63
75
|
options.assets === undefined
|
|
64
76
|
? Layer.empty
|
|
@@ -83,7 +95,7 @@ export function appHostHttp<
|
|
|
83
95
|
Layer.provide(NodeServices.layer),
|
|
84
96
|
);
|
|
85
97
|
}),
|
|
86
|
-
).pipe(Layer.provide(otlpTracing));
|
|
98
|
+
).pipe(Layer.provide(redactingDiagnostics(sensitive)), Layer.provide(otlpTracing), Layer.provide(Layer.succeed(RedactionPolicy, sensitive)));
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
export function migratedHost<
|
|
@@ -100,9 +112,10 @@ export function migratedHost<
|
|
|
100
112
|
options: HostOptions<TRpcs, THandlers, THandlerError, THandlerRequirements, TServices, TServiceError, TServiceRequirements> &
|
|
101
113
|
HostPolicy<TSession> & { readonly migrations: MigrationOptions<TMigrations> },
|
|
102
114
|
database: ReturnType<typeof databaseLayer<TMigrations>>,
|
|
115
|
+
changesService: typeof AppChanges,
|
|
103
116
|
) {
|
|
104
117
|
const services = Layer.mergeAll(options.services ?? Layer.empty);
|
|
105
|
-
return appHostHttp({ ...options, services: services.pipe(Layer.provideMerge(database)) });
|
|
118
|
+
return appHostHttp({ ...options, services: services.pipe(Layer.provideMerge(database)) }, changesService);
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
export function deferredHost<
|
|
@@ -124,6 +137,7 @@ export function deferredHost<
|
|
|
124
137
|
TApplicationRequirements
|
|
125
138
|
>,
|
|
126
139
|
database: ReturnType<typeof databaseLayer<TMigrations>>,
|
|
140
|
+
changesService: typeof AppChanges,
|
|
127
141
|
) {
|
|
128
|
-
return Layer.unwrap(Effect.map(application, (options) => appHostHttp(options))).pipe(Layer.provideMerge(database));
|
|
142
|
+
return Layer.unwrap(Effect.map(application, (options) => appHostHttp(options, changesService))).pipe(Layer.provideMerge(database));
|
|
129
143
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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/effect-contract/keys.ts";
|
|
6
|
+
import { CommandInvalidation } from "@shivaedev/effect-contract/serverMetadata.ts";
|
|
7
|
+
import { ChangeObserver, invalidateOnCommit, transact } from "@shivaedev/effect-sql/transact.ts";
|
|
8
|
+
|
|
9
|
+
export function commandChanges<A, E, R>(effect: Effect.Effect<A, E, R>, rpc: Rpc.AnyWithProps, payload: unknown) {
|
|
10
|
+
const invalidates = Context.getOption(rpc.annotations, CommandInvalidation);
|
|
11
|
+
if (Option.isNone(invalidates)) {
|
|
12
|
+
return effect;
|
|
13
|
+
}
|
|
14
|
+
const declared = invalidates.value;
|
|
15
|
+
return Effect.gen(function* () {
|
|
16
|
+
const sql = yield* Effect.serviceOption(SqlClient.SqlClient);
|
|
17
|
+
const reactivity = yield* Effect.serviceOption(Reactivity.Reactivity);
|
|
18
|
+
if (Option.isNone(reactivity)) {
|
|
19
|
+
return yield* effect;
|
|
20
|
+
}
|
|
21
|
+
const published = new Set<unknown>();
|
|
22
|
+
const observer = yield* ChangeObserver;
|
|
23
|
+
const result = yield* effect.pipe(
|
|
24
|
+
Effect.provideService(ChangeObserver, (event) => {
|
|
25
|
+
if (event._tag === "Published") {
|
|
26
|
+
for (const key of event.changes) {
|
|
27
|
+
published.add(key);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return observer(event);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
const keys = invalidationKeys(declared(payload, result)).filter((key) => !published.has(key));
|
|
34
|
+
if (keys.length > 0) {
|
|
35
|
+
const announce = Option.isNone(sql)
|
|
36
|
+
? reactivity.value.invalidate(keys)
|
|
37
|
+
: invalidateOnCommit(keys).pipe(
|
|
38
|
+
transact({ onSqlError: (error) => error }),
|
|
39
|
+
Effect.provideService(SqlClient.SqlClient, sql.value),
|
|
40
|
+
Effect.provideService(Reactivity.Reactivity, reactivity.value),
|
|
41
|
+
);
|
|
42
|
+
yield* announce.pipe(Effect.catchCause((cause) => Effect.logError("Committed command invalidation failed", cause)));
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import * as PgClient from "@effect/sql-pg/PgClient";
|
|
2
|
-
import { Config, Effect, Layer,
|
|
3
|
+
import { Config, Effect, Layer, Redacted, Schedule } from "effect";
|
|
3
4
|
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
4
5
|
import { makeLiveServer } from "@shivaedev/effect-contract/makeLiveServer.ts";
|
|
5
6
|
import { assertPostgresMigrations } from "@shivaedev/effect-sql/assertPostgresMigrations.ts";
|
|
@@ -11,7 +12,7 @@ import { databaseAddress } from "./databaseAddress.ts";
|
|
|
11
12
|
|
|
12
13
|
export interface DatabaseConfig extends SqlNameTransforms {
|
|
13
14
|
readonly applicationName?: string;
|
|
14
|
-
readonly changes?: Omit<PostgresChangesOptions, "client" | "resync"
|
|
15
|
+
readonly changes?: Partial<Omit<PostgresChangesOptions, "client" | "resync">>;
|
|
15
16
|
readonly databaseUrl?: Redacted.Redacted<string>;
|
|
16
17
|
readonly maxConnections?: number;
|
|
17
18
|
}
|
|
@@ -34,14 +35,14 @@ export function databaseLayer<R>(options: AppDatabaseOptions<R>, changesService:
|
|
|
34
35
|
const live = Layer.effect(changesService, makeLiveServer).pipe(Layer.provideMerge(Reactivity.layer));
|
|
35
36
|
const base = Layer.merge(prepared, live);
|
|
36
37
|
const changes = options.config?.changes;
|
|
37
|
-
if (changes === undefined) {
|
|
38
|
-
return base;
|
|
39
|
-
}
|
|
40
38
|
return Layer.unwrap(
|
|
41
39
|
Effect.gen(function* () {
|
|
42
40
|
const client = yield* PgClient.PgClient;
|
|
43
41
|
const hints = yield* changesService;
|
|
44
|
-
|
|
42
|
+
const url = options.config?.databaseUrl ?? (yield* Config.Redacted("DATABASE_URL"));
|
|
43
|
+
const name = yield* Config.String("APP_NAME").pipe(Config.withDefault(new URL(Redacted.value(url)).pathname));
|
|
44
|
+
const channel = changes?.channel ?? `changes_${createHash("sha256").update(name).digest("hex").slice(0, 32)}`;
|
|
45
|
+
return postgresChanges({ ...changes, channel, client, resync: hints.resync });
|
|
45
46
|
}),
|
|
46
47
|
).pipe(Layer.provideMerge(base));
|
|
47
48
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import type { Rpc, RpcGroup } from "effect/rpc";
|
|
3
|
+
import type { AppChanges } from "./appHost.ts";
|
|
4
|
+
|
|
5
|
+
export function hostChanges<R extends Rpc.Any>(group: RpcGroup.RpcGroup<R>, changesService: typeof AppChanges) {
|
|
6
|
+
return Layer.effectContext(
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const changes = yield* Effect.serviceOption(changesService);
|
|
9
|
+
if (Option.isNone(changes)) {
|
|
10
|
+
return Context.empty();
|
|
11
|
+
}
|
|
12
|
+
const live = changes.value;
|
|
13
|
+
const services = yield* Effect.context<never>();
|
|
14
|
+
const handlers = new Map<string, unknown>();
|
|
15
|
+
for (const rpc of group.requests.values()) {
|
|
16
|
+
if (!rpc._tag.endsWith(".$changes")) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
function handler({ keys }: { readonly keys: Parameters<typeof live.stream>[0] }) {
|
|
20
|
+
return live.stream(keys);
|
|
21
|
+
}
|
|
22
|
+
handlers.set(rpc.key, { context: services, handler, tag: rpc._tag });
|
|
23
|
+
}
|
|
24
|
+
return Context.makeUnsafe(handlers);
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -11,6 +11,7 @@ export type HostOptions<TRpcs extends Rpc.Any, THandlers, THandlerError, THandle
|
|
|
11
11
|
readonly host?: string;
|
|
12
12
|
readonly trustedProxy?: string;
|
|
13
13
|
readonly maxBodySize?: number;
|
|
14
|
+
readonly sensitiveKeys?: readonly string[];
|
|
14
15
|
readonly disablePreemptiveShutdown?: boolean;
|
|
15
16
|
};
|
|
16
17
|
readonly group: RpcGroup.RpcGroup<TRpcs>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Effect, Layer } from "effect";
|
|
2
2
|
import { FetchHttpClient, HttpClient, HttpClientError, type HttpClientRequest } from "effect/http";
|
|
3
|
+
import { RedactionPolicy } from "#server/RedactionPolicy.ts";
|
|
3
4
|
import { redactOtlpText } from "#server/safeOtlpSerialization.ts";
|
|
4
5
|
|
|
5
6
|
function timeoutError(request: HttpClientRequest.HttpClientRequest) {
|
|
@@ -13,6 +14,7 @@ export function otlpHttpClient(timeout = 10_000) {
|
|
|
13
14
|
HttpClient.HttpClient,
|
|
14
15
|
Effect.gen(function* () {
|
|
15
16
|
const client = yield* HttpClient.HttpClient;
|
|
17
|
+
const sensitive = yield* RedactionPolicy;
|
|
16
18
|
let warned = false;
|
|
17
19
|
function warnOnce(diagnostic: Effect.Effect<Record<string, unknown>>) {
|
|
18
20
|
return Effect.suspend(() => {
|
|
@@ -41,7 +43,7 @@ export function otlpHttpClient(timeout = 10_000) {
|
|
|
41
43
|
return warnOnce(
|
|
42
44
|
response.text.pipe(
|
|
43
45
|
Effect.orElseSucceed(() => "Unable to read collector response"),
|
|
44
|
-
Effect.map((body) => ({ "otlp.response": redactOtlpText(body), "otlp.status": response.status })),
|
|
46
|
+
Effect.map((body) => ({ "otlp.response": redactOtlpText(body, "", sensitive), "otlp.status": response.status })),
|
|
45
47
|
),
|
|
46
48
|
);
|
|
47
49
|
}),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Config, Effect, Layer, Schema } from "effect";
|
|
2
2
|
import { OtlpSerialization, OtlpTracer } from "effect/observability";
|
|
3
3
|
import { otlpHttpClient } from "#server/otlpHttpClient.ts";
|
|
4
|
+
import { RedactionPolicy } from "#server/RedactionPolicy.ts";
|
|
5
|
+
import { redactingTracer } from "#server/redactingTracer.ts";
|
|
4
6
|
import { safeOtlpSerialization } from "#server/safeOtlpSerialization.ts";
|
|
5
7
|
|
|
6
8
|
export const otlpTracing: Layer.Layer<never, Config.ConfigError> = Layer.unwrap(
|
|
@@ -41,7 +43,7 @@ export const otlpTracing: Layer.Layer<never, Config.ConfigError> = Layer.unwrap(
|
|
|
41
43
|
const exportInterval = yield* Config.Int("OTEL_BSP_SCHEDULE_DELAY").pipe(Config.withDefault(5000));
|
|
42
44
|
const maxBatchSize = yield* Config.Int("OTEL_BSP_MAX_EXPORT_BATCH_SIZE").pipe(Config.withDefault(1000));
|
|
43
45
|
const exportTimeout = yield* Config.Int("OTEL_BSP_EXPORT_TIMEOUT").pipe(Config.withDefault(3000));
|
|
44
|
-
|
|
46
|
+
const tracer = OtlpTracer.layer({
|
|
45
47
|
exportInterval,
|
|
46
48
|
headers,
|
|
47
49
|
maxBatchSize,
|
|
@@ -54,5 +56,6 @@ export const otlpTracing: Layer.Layer<never, Config.ConfigError> = Layer.unwrap(
|
|
|
54
56
|
),
|
|
55
57
|
Layer.provide(otlpHttpClient(requestTimeout)),
|
|
56
58
|
);
|
|
59
|
+
return redactingTracer(yield* RedactionPolicy).pipe(Layer.provideMerge(tracer));
|
|
57
60
|
}),
|
|
58
61
|
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Context, Effect, ErrorReporter, Layer, Logger, References } from "effect";
|
|
2
|
+
import { redact } from "#server/rpc-server/redact.ts";
|
|
3
|
+
import { redactDiagnosticCause, redactingErrorReporter } from "#server/rpc-server/redactCause.ts";
|
|
4
|
+
import type { SensitiveKey } from "#server/rpc-server/sensitive.ts";
|
|
5
|
+
|
|
6
|
+
export function redactRecord(values: Readonly<Record<string, unknown>>, sensitive: SensitiveKey): Record<string, unknown> {
|
|
7
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, sensitive(key) ? "<redacted>" : redact(value, sensitive)]));
|
|
8
|
+
}
|
|
9
|
+
export function redactingDiagnostics(sensitive: SensitiveKey) {
|
|
10
|
+
return Layer.effectContext(
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const loggers = yield* Logger.CurrentLoggers;
|
|
13
|
+
const reporters = yield* ErrorReporter.CurrentErrorReporters;
|
|
14
|
+
const safeLoggers = new Set(
|
|
15
|
+
[...loggers].map((logger) =>
|
|
16
|
+
Logger.make((options) => {
|
|
17
|
+
const context = options.fiber.context;
|
|
18
|
+
const annotations = redactRecord(options.fiber.getRef(References.CurrentLogAnnotations), sensitive);
|
|
19
|
+
options.fiber.setContext(Context.add(context, References.CurrentLogAnnotations, annotations));
|
|
20
|
+
try {
|
|
21
|
+
return logger.log({ ...options, cause: redactDiagnosticCause(options.cause, sensitive), message: redact(options.message, sensitive) });
|
|
22
|
+
} finally {
|
|
23
|
+
options.fiber.setContext(context);
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
return Context.make(Logger.CurrentLoggers, safeLoggers).pipe(
|
|
29
|
+
Context.add(ErrorReporter.CurrentErrorReporters, new Set([...reporters].map((reporter) => redactingErrorReporter(reporter, sensitive)))),
|
|
30
|
+
);
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Predicate } from "effect";
|
|
2
|
+
import type { RpcSerialization } from "effect/rpc";
|
|
3
|
+
import { redactWire } from "#server/rpc-server/redact.ts";
|
|
4
|
+
import { REDACTED, type SensitiveKey } from "#server/rpc-server/sensitive.ts";
|
|
5
|
+
|
|
6
|
+
function isRequestId(value: unknown): value is string | number {
|
|
7
|
+
return typeof value === "string" || typeof value === "number";
|
|
8
|
+
}
|
|
9
|
+
export function redactingRpcSerialization(
|
|
10
|
+
wire: RpcSerialization.RpcSerialization["Service"],
|
|
11
|
+
sensitive: SensitiveKey,
|
|
12
|
+
maskFailure: (tag: string | undefined, reason: "Fail" | "Die") => boolean = () => false,
|
|
13
|
+
): typeof wire {
|
|
14
|
+
function reason(value: unknown, tag: string | undefined): unknown {
|
|
15
|
+
const redacted = redactWire(value, sensitive);
|
|
16
|
+
if (Predicate.isObjectOrArray(redacted)) {
|
|
17
|
+
if (Predicate.isTagged(value, "Fail") && maskFailure(tag, "Fail")) {
|
|
18
|
+
return { ...redacted, _tag: "Fail", error: REDACTED };
|
|
19
|
+
}
|
|
20
|
+
if (Predicate.isTagged(value, "Die") && maskFailure(tag, "Die")) {
|
|
21
|
+
return { ...redacted, _tag: "Die", defect: REDACTED };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
(Predicate.isTagged(value, "Fail") || Predicate.isTagged(value, "Die") || Predicate.isTagged(value, "Interrupt"))
|
|
26
|
+
&& Predicate.isObjectOrArray(redacted)
|
|
27
|
+
) {
|
|
28
|
+
return { ...redacted, _tag: value._tag };
|
|
29
|
+
}
|
|
30
|
+
return redacted;
|
|
31
|
+
}
|
|
32
|
+
function exit(value: unknown, tag: string | undefined): unknown {
|
|
33
|
+
if (Predicate.isTagged(value, "Failure") && Predicate.hasProperty(value, "cause")) {
|
|
34
|
+
const cause = Array.isArray(value.cause) ? value.cause.map((item) => reason(item, tag)) : redactWire(value.cause, sensitive);
|
|
35
|
+
return { ...value, cause };
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...wire,
|
|
41
|
+
makeUnsafe: () => {
|
|
42
|
+
const parser = wire.makeUnsafe();
|
|
43
|
+
const requests = new Map<string | number, string>();
|
|
44
|
+
function response(value: unknown): unknown {
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
return value.map(response);
|
|
47
|
+
}
|
|
48
|
+
if (Predicate.isTagged(value, "Defect") && Predicate.hasProperty(value, "defect")) {
|
|
49
|
+
return { ...value, defect: maskFailure(undefined, "Die") ? REDACTED : redactWire(value.defect, sensitive) };
|
|
50
|
+
}
|
|
51
|
+
if (
|
|
52
|
+
Predicate.isTagged(value, "Exit")
|
|
53
|
+
&& Predicate.hasProperty(value, "requestId")
|
|
54
|
+
&& isRequestId(value.requestId)
|
|
55
|
+
&& Predicate.hasProperty(value, "exit")
|
|
56
|
+
) {
|
|
57
|
+
const tag = requests.get(value.requestId);
|
|
58
|
+
requests.delete(value.requestId);
|
|
59
|
+
return { ...value, exit: exit(value.exit, tag) };
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...parser,
|
|
65
|
+
decode: (data) => {
|
|
66
|
+
const decoded = parser.decode(data);
|
|
67
|
+
for (const request of decoded) {
|
|
68
|
+
if (
|
|
69
|
+
Predicate.isTagged(request, "Request")
|
|
70
|
+
&& Predicate.hasProperty(request, "id")
|
|
71
|
+
&& isRequestId(request.id)
|
|
72
|
+
&& Predicate.hasProperty(request, "tag")
|
|
73
|
+
&& typeof request.tag === "string"
|
|
74
|
+
&& !requests.has(request.id)
|
|
75
|
+
) {
|
|
76
|
+
requests.set(request.id, request.tag);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return decoded;
|
|
80
|
+
},
|
|
81
|
+
encode: (value) => parser.encode(response(value)),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect, Exit, Layer, Tracer } from "effect";
|
|
2
|
+
import { redactRecord } from "#server/redactingDiagnostics.ts";
|
|
3
|
+
import { redact } from "#server/rpc-server/redact.ts";
|
|
4
|
+
import { redactDiagnosticCause } from "#server/rpc-server/redactCause.ts";
|
|
5
|
+
import { REDACTED, type SensitiveKey } from "#server/rpc-server/sensitive.ts";
|
|
6
|
+
|
|
7
|
+
function redactLinks(values: readonly Tracer.SpanLink[], sensitive: SensitiveKey) {
|
|
8
|
+
return values.map((link) => ({ ...link, attributes: redactRecord(link.attributes, sensitive) }));
|
|
9
|
+
}
|
|
10
|
+
function redactSpan(span: Tracer.Span, sensitive: SensitiveKey) {
|
|
11
|
+
const end = span.end.bind(span);
|
|
12
|
+
const attribute = span.attribute.bind(span);
|
|
13
|
+
const event = span.event.bind(span);
|
|
14
|
+
const addLinks = span.addLinks.bind(span);
|
|
15
|
+
return Object.assign(span, {
|
|
16
|
+
addLinks: (values: readonly Tracer.SpanLink[]) => addLinks(redactLinks(values, sensitive)),
|
|
17
|
+
attribute: (key: string, value: unknown) => attribute(key, sensitive(key) ? REDACTED : redact(value, sensitive)),
|
|
18
|
+
end: (time: bigint, exit: Exit.Exit<unknown, unknown>) => {
|
|
19
|
+
for (const [key, value] of span.attributes) {
|
|
20
|
+
attribute(key, sensitive(key) ? REDACTED : redact(value, sensitive));
|
|
21
|
+
}
|
|
22
|
+
end(time, Exit.isFailure(exit) ? Exit.failCause(redactDiagnosticCause(exit.cause, sensitive)) : exit);
|
|
23
|
+
},
|
|
24
|
+
event: (name: string, time: bigint, attributes?: Record<string, unknown>) => event(name, time, attributes && redactRecord(attributes, sensitive)),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function redactingTracer(sensitive: SensitiveKey) {
|
|
28
|
+
return Layer.effect(
|
|
29
|
+
Tracer.Tracer,
|
|
30
|
+
Effect.map(Tracer.Tracer, (tracer) =>
|
|
31
|
+
Tracer.make({
|
|
32
|
+
...tracer,
|
|
33
|
+
span: (options) => redactSpan(tracer.span({ ...options, links: redactLinks(options.links, sensitive) }), sensitive),
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Predicate, Redacted } from "effect";
|
|
2
|
-
import { isSensitiveKey, REDACTED, redactText, type SensitiveKey } from "
|
|
2
|
+
import { isSensitiveKey, REDACTED, redactErrorStack, redactText, type SensitiveKey } from "#server/rpc-server/sensitive.ts";
|
|
3
3
|
|
|
4
4
|
const MAX_DEPTH = 8;
|
|
5
5
|
const MAX_ENTRIES = 50;
|
|
@@ -12,7 +12,7 @@ function binary(value: object): string | undefined {
|
|
|
12
12
|
}
|
|
13
13
|
return undefined;
|
|
14
14
|
}
|
|
15
|
-
function entriesOf(value: object): [string, unknown][] {
|
|
15
|
+
function entriesOf(value: object, sensitive: SensitiveKey, bounded: boolean): [string, unknown][] {
|
|
16
16
|
const entries = Object.entries(value);
|
|
17
17
|
if (!Predicate.isError(value)) {
|
|
18
18
|
return entries;
|
|
@@ -20,37 +20,45 @@ function entriesOf(value: object): [string, unknown][] {
|
|
|
20
20
|
const shape: [string, unknown][] = [
|
|
21
21
|
["name", value.name],
|
|
22
22
|
["message", value.message],
|
|
23
|
-
["stack", value.stack],
|
|
23
|
+
["stack", bounded ? redactErrorStack(value, sensitive) : value.stack],
|
|
24
24
|
];
|
|
25
|
-
|
|
25
|
+
const extra = entries.filter(([key]) => !["name", "message", "stack", "cause"].includes(key));
|
|
26
|
+
return value.cause === undefined ? [...shape, ...extra] : [...shape, ...extra, ["cause", value.cause]];
|
|
26
27
|
}
|
|
27
|
-
function
|
|
28
|
+
function collection(value: object, sensitive: SensitiveKey, depth: number, bounded: boolean): unknown {
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
const items = value.slice(0, bounded ? MAX_ENTRIES : value.length).map((item) => walk(item, sensitive, depth + 1, bounded));
|
|
31
|
+
return bounded && value.length > MAX_ENTRIES ? [...items, `<${value.length - MAX_ENTRIES} more items>`] : items;
|
|
32
|
+
}
|
|
33
|
+
const entries = entriesOf(value, sensitive, bounded);
|
|
34
|
+
const kept = entries.slice(0, bounded ? MAX_ENTRIES : entries.length).map(([key, nested]): [string, unknown] => {
|
|
35
|
+
const stackHeader = key === "stack" && typeof nested === "string" && (sensitive("name") || sensitive("message"));
|
|
36
|
+
return [key, sensitive(key) || stackHeader ? REDACTED : walk(nested, sensitive, depth + 1, bounded)];
|
|
37
|
+
});
|
|
38
|
+
return Object.fromEntries(bounded && entries.length > MAX_ENTRIES ? [...kept, ["<truncated>", `${entries.length - MAX_ENTRIES} more keys`]] : kept);
|
|
39
|
+
}
|
|
40
|
+
function walk(value: unknown, sensitive: SensitiveKey, depth: number, bounded: boolean): unknown {
|
|
28
41
|
if (Redacted.isRedacted(value)) {
|
|
29
42
|
return REDACTED;
|
|
30
43
|
}
|
|
31
44
|
if (Predicate.isString(value)) {
|
|
32
|
-
return redactText(value, sensitive);
|
|
45
|
+
return bounded ? redactText(value, sensitive) : redactText(value, sensitive, Number.POSITIVE_INFINITY);
|
|
33
46
|
}
|
|
34
47
|
if (!Predicate.isObjectOrArray(value)) {
|
|
35
48
|
return value;
|
|
36
49
|
}
|
|
37
50
|
const summary = binary(value);
|
|
38
51
|
if (summary !== undefined) {
|
|
39
|
-
return summary;
|
|
52
|
+
return bounded ? summary : value;
|
|
40
53
|
}
|
|
41
|
-
if (depth >= MAX_DEPTH) {
|
|
54
|
+
if (bounded && depth >= MAX_DEPTH) {
|
|
42
55
|
return "<truncated>";
|
|
43
56
|
}
|
|
44
|
-
|
|
45
|
-
const items = value.slice(0, MAX_ENTRIES).map((item) => walk(item, sensitive, depth + 1));
|
|
46
|
-
return value.length > MAX_ENTRIES ? [...items, `<${value.length - MAX_ENTRIES} more items>`] : items;
|
|
47
|
-
}
|
|
48
|
-
const entries = entriesOf(value);
|
|
49
|
-
const kept = entries
|
|
50
|
-
.slice(0, MAX_ENTRIES)
|
|
51
|
-
.map(([key, nested]): [string, unknown] => [key, sensitive(key) ? REDACTED : walk(nested, sensitive, depth + 1)]);
|
|
52
|
-
return Object.fromEntries(entries.length > MAX_ENTRIES ? [...kept, ["<truncated>", `${entries.length - MAX_ENTRIES} more keys`]] : kept);
|
|
57
|
+
return collection(value, sensitive, depth, bounded);
|
|
53
58
|
}
|
|
54
59
|
export function redact(value: unknown, sensitive: SensitiveKey = isSensitiveKey): unknown {
|
|
55
|
-
return walk(value, sensitive, 0);
|
|
60
|
+
return walk(value, sensitive, 0, true);
|
|
61
|
+
}
|
|
62
|
+
export function redactWire(value: unknown, sensitive: SensitiveKey = isSensitiveKey): unknown {
|
|
63
|
+
return walk(value, sensitive, 0, false);
|
|
56
64
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Cause, Context, ErrorReporter, Predicate } from "effect";
|
|
2
|
-
import { redact } from "
|
|
3
|
-
import { isSensitiveKey, redactText, type SensitiveKey } from "
|
|
1
|
+
import { Cause, Context, ErrorReporter, Predicate, type References } from "effect";
|
|
2
|
+
import { redact } from "#server/rpc-server/redact.ts";
|
|
3
|
+
import { isSensitiveKey, REDACTED, redactErrorStack, redactText, type SensitiveKey } from "#server/rpc-server/sensitive.ts";
|
|
4
4
|
|
|
5
5
|
function reporterHints(error: Error, sensitive: SensitiveKey) {
|
|
6
6
|
return {
|
|
@@ -9,16 +9,24 @@ function reporterHints(error: Error, sensitive: SensitiveKey) {
|
|
|
9
9
|
...(ErrorReporter.attributes in error ? { [ErrorReporter.attributes]: redact(ErrorReporter.getAttributes(error), sensitive) } : {}),
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
|
+
function errorText(key: string, value: string, sensitive: SensitiveKey): string {
|
|
13
|
+
return sensitive(key) ? REDACTED : redactText(value, sensitive);
|
|
14
|
+
}
|
|
12
15
|
function redactError(error: Error, sensitive: SensitiveKey): Error {
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
+
const cause = sensitive("cause") ? REDACTED : redact(error.cause, sensitive);
|
|
17
|
+
const options = error.cause === undefined ? undefined : { cause };
|
|
18
|
+
const copy = Object.assign(
|
|
19
|
+
new Error(errorText("message", error.message, sensitive), options),
|
|
20
|
+
redact(Object.fromEntries(Object.entries(error)), sensitive),
|
|
21
|
+
reporterHints(error, sensitive),
|
|
22
|
+
);
|
|
23
|
+
Object.defineProperty(copy, "name", { configurable: true, value: errorText("name", error.name, sensitive), writable: true });
|
|
16
24
|
Object.defineProperty(copy, "stack", {
|
|
17
25
|
configurable: true,
|
|
18
|
-
value:
|
|
26
|
+
value: redactErrorStack(error, sensitive),
|
|
19
27
|
writable: true,
|
|
20
28
|
});
|
|
21
|
-
return
|
|
29
|
+
return copy;
|
|
22
30
|
}
|
|
23
31
|
export function redactDefect(defect: unknown, sensitive: SensitiveKey = isSensitiveKey): unknown {
|
|
24
32
|
return Predicate.isError(defect) ? redactError(defect, sensitive) : redact(defect, sensitive);
|
|
@@ -32,9 +40,65 @@ export function redactCause<E>(cause: Cause.Cause<E>, sensitive: SensitiveKey =
|
|
|
32
40
|
),
|
|
33
41
|
);
|
|
34
42
|
}
|
|
43
|
+
function diagnosticReason(reason: Cause.Reason<unknown>, sensitive: SensitiveKey): Cause.Cause<unknown> {
|
|
44
|
+
switch (reason._tag) {
|
|
45
|
+
case "Fail":
|
|
46
|
+
return Cause.fail(redactDefect(reason.error, sensitive));
|
|
47
|
+
case "Die":
|
|
48
|
+
return Cause.die(redactDefect(reason.defect, sensitive));
|
|
49
|
+
case "Interrupt":
|
|
50
|
+
return Cause.interrupt(reason.fiberId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function stackFrame(frame: References.StackFrame | undefined, sensitive: SensitiveKey, remaining = 8): References.StackFrame | undefined {
|
|
54
|
+
if (frame === undefined || remaining === 0) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name: sensitive("name") ? REDACTED : redactText(frame.name, sensitive),
|
|
59
|
+
parent: stackFrame(frame.parent, sensitive, remaining - 1),
|
|
60
|
+
stack: () => {
|
|
61
|
+
const value = frame.stack();
|
|
62
|
+
if (value === undefined) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return sensitive("name") || sensitive("message") ? REDACTED : redactText(value, sensitive);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function diagnosticAnnotations(reason: Cause.Reason<unknown>, sensitive: SensitiveKey) {
|
|
70
|
+
const nativeStacks: Context.Key<Cause.StackTrace | Cause.InterruptorStackTrace, References.StackFrame>[] = [
|
|
71
|
+
Cause.StackTrace,
|
|
72
|
+
Cause.InterruptorStackTrace,
|
|
73
|
+
];
|
|
74
|
+
const annotations = new Map(
|
|
75
|
+
[...reason.annotations]
|
|
76
|
+
.filter(([key]) => !nativeStacks.some((service) => service.key === key))
|
|
77
|
+
.map(([key, value]) => [key, sensitive(key) ? REDACTED : redact(value, sensitive)]),
|
|
78
|
+
);
|
|
79
|
+
if (!sensitive("stack")) {
|
|
80
|
+
const context = Cause.reasonAnnotations(reason);
|
|
81
|
+
for (const service of nativeStacks) {
|
|
82
|
+
const frame = Context.getOrUndefined(context, service);
|
|
83
|
+
if (frame !== undefined && !sensitive(service.key)) {
|
|
84
|
+
annotations.set(service.key, stackFrame(frame, sensitive));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return annotations;
|
|
89
|
+
}
|
|
90
|
+
export function redactDiagnosticCause(cause: Cause.Cause<unknown>, sensitive: SensitiveKey = isSensitiveKey): Cause.Cause<unknown> {
|
|
91
|
+
return Cause.fromReasons(
|
|
92
|
+
cause.reasons.flatMap((reason) => {
|
|
93
|
+
const redacted = diagnosticReason(reason, sensitive);
|
|
94
|
+
const annotations = diagnosticAnnotations(reason, sensitive);
|
|
95
|
+
return Cause.annotate(redacted, Context.makeUnsafe(annotations), { overwrite: true }).reasons;
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
35
99
|
export function redactingErrorReporter(reporter: ErrorReporter.ErrorReporter, sensitive: SensitiveKey = isSensitiveKey): ErrorReporter.ErrorReporter {
|
|
36
100
|
return {
|
|
37
101
|
[ErrorReporter.TypeId]: ErrorReporter.TypeId,
|
|
38
|
-
report: (options) => reporter.report({ ...options, cause:
|
|
102
|
+
report: (options) => reporter.report({ ...options, cause: redactDiagnosticCause(options.cause, sensitive) }),
|
|
39
103
|
};
|
|
40
104
|
}
|
|
@@ -67,7 +67,13 @@ function scrub(text: string, sensitive: SensitiveKey): string {
|
|
|
67
67
|
.replaceAll(URL_PASSWORD, `$<authority>${REDACTED}@`);
|
|
68
68
|
return redactAssignments(credentials, sensitive);
|
|
69
69
|
}
|
|
70
|
-
export function redactText(text: string, sensitive: SensitiveKey): string {
|
|
70
|
+
export function redactText(text: string, sensitive: SensitiveKey, maxLength = MAX_STRING): string {
|
|
71
71
|
const scrubbed = scrub(text, sensitive);
|
|
72
|
-
return scrubbed.length >
|
|
72
|
+
return scrubbed.length > maxLength ? `${scrubbed.slice(0, maxLength)}…<${scrubbed.length - maxLength} more chars>` : scrubbed;
|
|
73
|
+
}
|
|
74
|
+
export function redactErrorStack(error: Error, sensitive: SensitiveKey): string | undefined {
|
|
75
|
+
if (error.stack === undefined) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return ["message", "name", "stack"].some((key) => sensitive(key)) ? REDACTED : redactText(error.stack, sensitive);
|
|
73
79
|
}
|