@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +30 -9
  3. package/dist/server/RedactionPolicy.d.ts +4 -0
  4. package/dist/server/RedactionPolicy.d.ts.map +1 -0
  5. package/dist/server/RedactionPolicy.js +4 -0
  6. package/dist/server/RedactionPolicy.js.map +1 -0
  7. package/dist/server/appHost.js +3 -3
  8. package/dist/server/appHost.js.map +1 -1
  9. package/dist/server/appHostHttp.d.ts +4 -4
  10. package/dist/server/appHostHttp.d.ts.map +1 -1
  11. package/dist/server/appHostHttp.js +17 -11
  12. package/dist/server/appHostHttp.js.map +1 -1
  13. package/dist/server/commandChanges.d.ts +4 -0
  14. package/dist/server/commandChanges.d.ts.map +1 -0
  15. package/dist/server/commandChanges.js +39 -0
  16. package/dist/server/commandChanges.js.map +1 -0
  17. package/dist/server/databaseLayer.d.ts +2 -2
  18. package/dist/server/databaseLayer.d.ts.map +1 -1
  19. package/dist/server/databaseLayer.js +6 -5
  20. package/dist/server/databaseLayer.js.map +1 -1
  21. package/dist/server/hostChanges.d.ts +5 -0
  22. package/dist/server/hostChanges.d.ts.map +1 -0
  23. package/dist/server/hostChanges.js +23 -0
  24. package/dist/server/hostChanges.js.map +1 -0
  25. package/dist/server/hostOptions.d.ts +1 -0
  26. package/dist/server/hostOptions.d.ts.map +1 -1
  27. package/dist/server/otlpHttpClient.d.ts.map +1 -1
  28. package/dist/server/otlpHttpClient.js +3 -1
  29. package/dist/server/otlpHttpClient.js.map +1 -1
  30. package/dist/server/otlpTracing.d.ts.map +1 -1
  31. package/dist/server/otlpTracing.js +4 -1
  32. package/dist/server/otlpTracing.js.map +1 -1
  33. package/dist/server/redactingDiagnostics.d.ts +5 -0
  34. package/dist/server/redactingDiagnostics.d.ts.map +1 -0
  35. package/dist/server/redactingDiagnostics.js +25 -0
  36. package/dist/server/redactingDiagnostics.js.map +1 -0
  37. package/dist/server/redactingRpcSerialization.d.ts +4 -0
  38. package/dist/server/redactingRpcSerialization.d.ts.map +1 -0
  39. package/dist/server/redactingRpcSerialization.js +74 -0
  40. package/dist/server/redactingRpcSerialization.js.map +1 -0
  41. package/dist/server/redactingTracer.d.ts +4 -0
  42. package/dist/server/redactingTracer.d.ts.map +1 -0
  43. package/dist/server/redactingTracer.js +32 -0
  44. package/dist/server/redactingTracer.js.map +1 -0
  45. package/dist/server/rpc-server/redact.d.ts +2 -1
  46. package/dist/server/rpc-server/redact.d.ts.map +1 -1
  47. package/dist/server/rpc-server/redact.js +26 -18
  48. package/dist/server/rpc-server/redact.js.map +1 -1
  49. package/dist/server/rpc-server/redactCause.d.ts +2 -1
  50. package/dist/server/rpc-server/redactCause.d.ts.map +1 -1
  51. package/dist/server/rpc-server/redactCause.js +64 -8
  52. package/dist/server/rpc-server/redactCause.js.map +1 -1
  53. package/dist/server/rpc-server/sensitive.d.ts +2 -1
  54. package/dist/server/rpc-server/sensitive.d.ts.map +1 -1
  55. package/dist/server/rpc-server/sensitive.js +8 -2
  56. package/dist/server/rpc-server/sensitive.js.map +1 -1
  57. package/dist/server/rpc-server/tracing.d.ts +1 -1
  58. package/dist/server/rpc-server/tracing.d.ts.map +1 -1
  59. package/dist/server/rpc-server/tracing.js +11 -4
  60. package/dist/server/rpc-server/tracing.js.map +1 -1
  61. package/dist/server/safeOtlpSerialization.d.ts +2 -1
  62. package/dist/server/safeOtlpSerialization.d.ts.map +1 -1
  63. package/dist/server/safeOtlpSerialization.js +32 -21
  64. package/dist/server/safeOtlpSerialization.js.map +1 -1
  65. package/dist/server/schemaSensitiveKeys.d.ts +5 -0
  66. package/dist/server/schemaSensitiveKeys.d.ts.map +1 -0
  67. package/dist/server/schemaSensitiveKeys.js +143 -0
  68. package/dist/server/schemaSensitiveKeys.js.map +1 -0
  69. package/package.json +6 -6
  70. package/src/server/RedactionPolicy.ts +4 -0
  71. package/src/server/appHost.ts +3 -3
  72. package/src/server/appHostHttp.ts +23 -9
  73. package/src/server/commandChanges.ts +46 -0
  74. package/src/server/databaseLayer.ts +7 -6
  75. package/src/server/hostChanges.ts +27 -0
  76. package/src/server/hostOptions.ts +1 -0
  77. package/src/server/otlpHttpClient.ts +3 -1
  78. package/src/server/otlpTracing.ts +4 -1
  79. package/src/server/redactingDiagnostics.ts +33 -0
  80. package/src/server/redactingRpcSerialization.ts +85 -0
  81. package/src/server/redactingTracer.ts +37 -0
  82. package/src/server/rpc-server/redact.ts +26 -18
  83. package/src/server/rpc-server/redactCause.ts +73 -9
  84. package/src/server/rpc-server/sensitive.ts +8 -2
  85. package/src/server/rpc-server/tracing.ts +11 -5
  86. package/src/server/safeOtlpSerialization.ts +44 -34
  87. 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(Layer.provide(Layer.merge(options.handlers, middleware)), Layer.provide(RpcSerialization.layerNdjson));
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, type Redacted, Schedule } from "effect";
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
- return postgresChanges({ ...changes, client, resync: hints.resync });
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
- return OtlpTracer.layer({
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 "./sensitive.ts";
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
- return value.cause === undefined ? [...shape, ...entries] : [...shape, ...entries, ["cause", value.cause]];
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 walk(value: unknown, sensitive: SensitiveKey, depth: number): unknown {
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
- if (Array.isArray(value)) {
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 "./redact.ts";
3
- import { isSensitiveKey, redactText, type SensitiveKey } from "./sensitive.ts";
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 options = error.cause === undefined ? undefined : { cause: redact(error.cause, sensitive) };
14
- const copy = new Error(redactText(error.message, sensitive), options);
15
- Object.defineProperty(copy, "name", { configurable: true, value: error.name, writable: true });
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: error.stack === undefined ? undefined : redactText(error.stack, sensitive),
26
+ value: redactErrorStack(error, sensitive),
19
27
  writable: true,
20
28
  });
21
- return Object.assign(copy, redact(Object.fromEntries(Object.entries(error)), sensitive), reporterHints(error, sensitive));
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: redactCause(options.cause, sensitive) }),
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 > MAX_STRING ? `${scrubbed.slice(0, MAX_STRING)}…<${scrubbed.length - MAX_STRING} more chars>` : scrubbed;
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
  }