authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:27:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:27:28-07:00
log7b791895ad043e180c1d56595a08866e16d0766e
tree0f3b8206501f4d5d4ff6bd41c678033394a10482
parent4c16c66368c934a3f23794fc4826a2c77fe9f323
signature Signed by PGP key B5690EEEBB952194

[codex] Structure relay auth persistence errors (#3250)

Co-authored-by: codex <codex@users.noreply.github.com>

9 files changed, 614 insertions(+), 209 deletions(-)

infra/relay/src/auth/DpopProofs.test.ts+26
...@@ -96,4 +96,30 @@ describe("DpopProofReplay", () => {...@@ -96,4 +96,30 @@ describe("DpopProofReplay", () => {
96 Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),96 Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
97 );97 );
98 });98 });
99
100 it.effect("retains the prune cutoff and database failure", () => {
101 const cause = new Error("database unavailable");
102 const fakeDb = {
103 delete: (table: unknown) => {
104 expect(table).toBe(relayDpopProofs);
105 return {
106 where: () => Effect.fail(cause),
107 };
108 },
109 } as unknown as RelayDb.RelayDb["Service"];
110
111 return Effect.gen(function* () {
112 const replay = yield* DpopProofs.DpopProofReplay;
113 const error = yield* Effect.flip(replay.pruneExpired);
114
115 expect(error).toMatchObject({
116 _tag: "DpopProofReplayPersistenceError",
117 operation: "prune-expired",
118 });
119 expect(Date.parse(error.expiresBefore ?? "")).not.toBeNaN();
120 expect(error.cause).toBe(cause);
121 }).pipe(
122 Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
123 );
124 });
99});125});
infra/relay/src/auth/DpopProofs.ts+33-8
...@@ -13,11 +13,16 @@ import { relayDpopProofs } from "../persistence/schema.ts";...@@ -13,11 +13,16 @@ import { relayDpopProofs } from "../persistence/schema.ts";
13export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass<DpopProofReplayPersistenceError>()(13export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass<DpopProofReplayPersistenceError>()(
14 "DpopProofReplayPersistenceError",14 "DpopProofReplayPersistenceError",
15 {15 {
16 operation: Schema.Literals(["consume", "prune-expired"]),
17 thumbprint: Schema.optionalKey(Schema.String),
18 jti: Schema.optionalKey(Schema.String),
19 iat: Schema.optionalKey(Schema.Number),
20 expiresBefore: Schema.optionalKey(Schema.String),
16 cause: Schema.Defect(),21 cause: Schema.Defect(),
17 },22 },
18) {23) {
19 override get message(): string {24 override get message(): string {
20 return "Failed to persist DPoP proof replay state";25 return `Failed to persist DPoP proof replay state during '${this.operation}'`;
21 }26 }
22}27}
2328
...@@ -58,10 +63,21 @@ const make = Effect.gen(function* () {...@@ -58,10 +63,21 @@ const make = Effect.gen(function* () {
58 createdAt,63 createdAt,
59 })64 })
60 .onConflictDoNothing()65 .onConflictDoNothing()
61 .returning({ jti: relayDpopProofs.jti });66 .returning({ jti: relayDpopProofs.jti })
67 .pipe(
68 Effect.mapError(
69 (cause) =>
70 new DpopProofReplayPersistenceError({
71 operation: "consume",
72 thumbprint: input.thumbprint,
73 jti: input.jti,
74 iat: input.iat,
75 cause,
76 }),
77 ),
78 );
62 return inserted.length > 0;79 return inserted.length > 0;
63 },80 },
64 Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })),
65 );81 );
6682
67 const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn(83 const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn(
...@@ -114,11 +130,20 @@ const make = Effect.gen(function* () {...@@ -114,11 +130,20 @@ const make = Effect.gen(function* () {
114 const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () {130 const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () {
115 const now = DateTime.formatIso(yield* DateTime.now);131 const now = DateTime.formatIso(yield* DateTime.now);
116 yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now });132 yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now });
117 yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now));133 yield* db
118 }).pipe(134 .delete(relayDpopProofs)
119 Effect.withSpan("relay.dpop_proofs.prune_expired"),135 .where(lt(relayDpopProofs.expiresAt, now))
120 Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })),136 .pipe(
121 );137 Effect.mapError(
138 (cause) =>
139 new DpopProofReplayPersistenceError({
140 operation: "prune-expired",
141 expiresBefore: now,
142 cause,
143 }),
144 ),
145 );
146 }).pipe(Effect.withSpan("relay.dpop_proofs.prune_expired"));
122147
123 return DpopProofReplay.of({148 return DpopProofReplay.of({
124 verifyAndConsume,149 verifyAndConsume,
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts+11-3
...@@ -163,7 +163,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {...@@ -163,7 +163,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
163 iat: Math.floor(now.epochMilliseconds / 1_000),163 iat: Math.floor(now.epochMilliseconds / 1_000),
164 jti: "proof-persistence-failure",164 jti: "proof-persistence-failure",
165 });165 });
166 const cause = "database unavailable";166 const cause = { _tag: "DatabaseUnavailable" } as const;
167167
168 return Effect.gen(function* () {168 return Effect.gen(function* () {
169 const replay = yield* DpopProofs.DpopProofReplay;169 const replay = yield* DpopProofs.DpopProofReplay;
...@@ -177,8 +177,16 @@ describe("DpopProofReplay.verifyAndConsume", () => {...@@ -177,8 +177,16 @@ describe("DpopProofReplay.verifyAndConsume", () => {
177 }),177 }),
178 );178 );
179179
180 expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause }));180 expect(error).toMatchObject({
181 }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause }))));181 _tag: "DpopProofReplayPersistenceError",
182 operation: "consume",
183 thumbprint: proof.thumbprint,
184 jti: "proof-persistence-failure",
185 iat: Math.floor(now.epochMilliseconds / 1_000),
186 });
187 expect(error.cause).toBe(cause);
188 expect(error).not.toHaveProperty("proof");
189 }).pipe(Effect.provide(layer(() => Effect.fail(cause))));
182 });190 });
183191
184 it.effect("accepts proofs bound to the access token hash", () => {192 it.effect("accepts proofs bound to the access token hash", () => {
infra/relay/src/environments/EnvironmentCredentials.test.ts+82
...@@ -9,6 +9,88 @@ import { relayEnvironmentCredentials } from "../persistence/schema.ts";...@@ -9,6 +9,88 @@ import { relayEnvironmentCredentials } from "../persistence/schema.ts";
9import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";9import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";
1010
11describe("EnvironmentCredentials", () => {11describe("EnvironmentCredentials", () => {
12 it.effect("reports the credential creation persistence stage and preserves its cause", () => {
13 const cause = new Error("database unavailable");
14 const fakeDb = {
15 insert: (table: unknown) => {
16 expect(table).toBe(relayEnvironmentCredentials);
17 return {
18 values: () => Effect.void,
19 };
20 },
21 update: (table: unknown) => {
22 expect(table).toBe(relayEnvironmentCredentials);
23 return {
24 set: () => ({
25 where: () => Effect.fail(cause),
26 }),
27 };
28 },
29 } as unknown as RelayDb.RelayDb["Service"];
30
31 return Effect.gen(function* () {
32 const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
33 const error = yield* Effect.flip(
34 credentials.create({
35 environmentId: "env_test",
36 environmentPublicKey: "sensitive-public-key-material",
37 }),
38 );
39
40 expect(error).toMatchObject({
41 _tag: "EnvironmentCredentialCreatePersistenceError",
42 stage: "revoke-previous-credentials",
43 environmentId: "env_test",
44 });
45 expect(error.credentialId).toMatch(/^[0-9a-f]{64}$/);
46 expect(error.cause).toBe(cause);
47 expect(error).not.toHaveProperty("environmentPublicKey");
48 }).pipe(
49 Effect.provide(
50 EnvironmentCredentials.layer.pipe(
51 Layer.provide(NodeCryptoLayer.layer),
52 Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)),
53 ),
54 ),
55 );
56 });
57
58 it.effect("does not retain credential tokens when lookup persistence fails", () => {
59 const cause = new Error("database unavailable");
60 const token = "t3env_sensitive-credential-token";
61 const fakeDb = {
62 select: () => ({
63 from: (table: unknown) => {
64 expect(table).toBe(relayEnvironmentCredentials);
65 return {
66 where: () => ({
67 limit: () => Effect.fail(cause),
68 }),
69 };
70 },
71 }),
72 } as unknown as RelayDb.RelayDb["Service"];
73
74 return Effect.gen(function* () {
75 const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
76 const error = yield* Effect.flip(credentials.authenticate(token));
77
78 expect(error).toMatchObject({
79 _tag: "EnvironmentCredentialAuthenticatePersistenceError",
80 stage: "lookup-credential",
81 });
82 expect(error.cause).toBe(cause);
83 expect(error).not.toHaveProperty("token");
84 }).pipe(
85 Effect.provide(
86 EnvironmentCredentials.layer.pipe(
87 Layer.provide(NodeCryptoLayer.layer),
88 Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)),
89 ),
90 ),
91 );
92 });
93
12 it.effect(94 it.effect(
13 "creates opaque credentials and revokes only older credentials for the same key",95 "creates opaque credentials and revokes only older credentials for the same key",
14 () => {96 () => {
infra/relay/src/environments/EnvironmentCredentials.ts+172-96
...@@ -13,28 +13,44 @@ import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persisten...@@ -13,28 +13,44 @@ import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persisten
1313
14export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialCreatePersistenceError>()(14export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialCreatePersistenceError>()(
15 "EnvironmentCredentialCreatePersistenceError",15 "EnvironmentCredentialCreatePersistenceError",
16 { cause: Schema.Defect() },16 {
17 stage: Schema.Literals([
18 "generate-credential",
19 "hash-token",
20 "insert-credential",
21 "revoke-previous-credentials",
22 ]),
23 environmentId: Schema.String,
24 credentialId: Schema.optionalKey(Schema.String),
25 cause: Schema.Defect(),
26 },
17) {27) {
18 override get message(): string {28 override get message(): string {
19 return "Failed to persist environment credential";29 return `Environment credential creation failed during '${this.stage}' for environment '${this.environmentId}'${this.credentialId === undefined ? "" : `, credential '${this.credentialId}'`}`;
20 }30 }
21}31}
2232
23export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialAuthenticatePersistenceError>()(33export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialAuthenticatePersistenceError>()(
24 "EnvironmentCredentialAuthenticatePersistenceError",34 "EnvironmentCredentialAuthenticatePersistenceError",
25 { cause: Schema.Defect() },35 {
36 stage: Schema.Literals(["hash-token", "lookup-credential"]),
37 cause: Schema.Defect(),
38 },
26) {39) {
27 override get message(): string {40 override get message(): string {
28 return "Failed to authenticate environment credential";41 return `Environment credential authentication failed during '${this.stage}'`;
29 }42 }
30}43}
3144
32export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialRevokePersistenceError>()(45export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialRevokePersistenceError>()(
33 "EnvironmentCredentialRevokePersistenceError",46 "EnvironmentCredentialRevokePersistenceError",
34 { cause: Schema.Defect() },47 {
48 environmentId: Schema.String,
49 cause: Schema.Defect(),
50 },
35) {51) {
36 override get message(): string {52 override get message(): string {
37 return "Failed to revoke environment credential";53 return `Failed to revoke credentials for environment '${this.environmentId}'`;
38 }54 }
39}55}
4056
...@@ -85,13 +101,33 @@ const make = Effect.gen(function* () {...@@ -85,13 +101,33 @@ const make = Effect.gen(function* () {
85 });101 });
86102
87 return EnvironmentCredentials.of({103 return EnvironmentCredentials.of({
88 create: Effect.fn("relay.environment_credentials.create")(104 create: Effect.fn("relay.environment_credentials.create")(function* (input) {
89 function* (input) {105 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
90 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });106 const credential = yield* makeCredential().pipe(
91 const credential = yield* makeCredential();107 Effect.mapError(
92 const credentialHash = yield* hashToken(credential.token);108 (cause) =>
93 const now = DateTime.formatIso(yield* DateTime.now);109 new EnvironmentCredentialCreatePersistenceError({
94 yield* db.insert(relayEnvironmentCredentials).values({110 stage: "generate-credential",
111 environmentId: input.environmentId,
112 cause,
113 }),
114 ),
115 );
116 const credentialHash = yield* hashToken(credential.token).pipe(
117 Effect.mapError(
118 (cause) =>
119 new EnvironmentCredentialCreatePersistenceError({
120 stage: "hash-token",
121 environmentId: input.environmentId,
122 credentialId: credential.credentialId,
123 cause,
124 }),
125 ),
126 );
127 const now = DateTime.formatIso(yield* DateTime.now);
128 yield* db
129 .insert(relayEnvironmentCredentials)
130 .values({
95 credentialId: credential.credentialId,131 credentialId: credential.credentialId,
96 environmentId: input.environmentId,132 environmentId: input.environmentId,
97 environmentPublicKey: input.environmentPublicKey,133 environmentPublicKey: input.environmentPublicKey,
...@@ -99,96 +135,136 @@ const make = Effect.gen(function* () {...@@ -99,96 +135,136 @@ const make = Effect.gen(function* () {
99 revokedAt: null,135 revokedAt: null,
100 createdAt: now,136 createdAt: now,
101 updatedAt: now,137 updatedAt: now,
102 });138 })
103 yield* db139 .pipe(
104 .update(relayEnvironmentCredentials)140 Effect.mapError(
105 .set({141 (cause) =>
106 revokedAt: now,142 new EnvironmentCredentialCreatePersistenceError({
107 updatedAt: now,143 stage: "insert-credential",
108 })144 environmentId: input.environmentId,
109 .where(145 credentialId: credential.credentialId,
110 and(146 cause,
111 eq(relayEnvironmentCredentials.environmentId, input.environmentId),147 }),
112 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),148 ),
113 ne(relayEnvironmentCredentials.credentialId, credential.credentialId),149 );
114 isNull(relayEnvironmentCredentials.revokedAt),150 yield* db
115 ),151 .update(relayEnvironmentCredentials)
116 );152 .set({
117 return credential.token;153 revokedAt: now,
118 },154 updatedAt: now,
119 Effect.mapError((cause) => new EnvironmentCredentialCreatePersistenceError({ cause })),155 })
120 ),156 .where(
157 and(
158 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
159 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
160 ne(relayEnvironmentCredentials.credentialId, credential.credentialId),
161 isNull(relayEnvironmentCredentials.revokedAt),
162 ),
163 )
164 .pipe(
165 Effect.mapError(
166 (cause) =>
167 new EnvironmentCredentialCreatePersistenceError({
168 stage: "revoke-previous-credentials",
169 environmentId: input.environmentId,
170 credentialId: credential.credentialId,
171 cause,
172 }),
173 ),
174 );
175 return credential.token;
176 }),
121177
122 authenticate: Effect.fn("relay.environment_credentials.authenticate")(178 authenticate: Effect.fn("relay.environment_credentials.authenticate")(function* (token) {
123 function* (token) {179 const credentialHash = yield* hashToken(token).pipe(
124 const credentialHash = yield* hashToken(token);180 Effect.mapError(
125 const rows = yield* db181 (cause) =>
126 .select({182 new EnvironmentCredentialAuthenticatePersistenceError({
127 credentialId: relayEnvironmentCredentials.credentialId,183 stage: "hash-token",
128 environmentId: relayEnvironmentCredentials.environmentId,184 cause,
129 environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey,185 }),
186 ),
187 );
188 const rows = yield* db
189 .select({
190 credentialId: relayEnvironmentCredentials.credentialId,
191 environmentId: relayEnvironmentCredentials.environmentId,
192 environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey,
193 })
194 .from(relayEnvironmentCredentials)
195 .where(
196 and(
197 eq(relayEnvironmentCredentials.credentialHash, credentialHash),
198 isNull(relayEnvironmentCredentials.revokedAt),
199 ),
200 )
201 .limit(1)
202 .pipe(
203 Effect.mapError(
204 (cause) =>
205 new EnvironmentCredentialAuthenticatePersistenceError({
206 stage: "lookup-credential",
207 cause,
208 }),
209 ),
210 );
211 const row = rows[0];
212 if (row) {
213 yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId });
214 }
215 return row
216 ? Option.some({
217 credentialId: row.credentialId,
218 environmentId: row.environmentId,
219 environmentPublicKey: row.environmentPublicKey,
130 })220 })
131 .from(relayEnvironmentCredentials)221 : Option.none();
132 .where(222 }),
133 and(
134 eq(relayEnvironmentCredentials.credentialHash, credentialHash),
135 isNull(relayEnvironmentCredentials.revokedAt),
136 ),
137 )
138 .limit(1);
139 const row = rows[0];
140 if (row) {
141 yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId });
142 }
143 return row
144 ? Option.some({
145 credentialId: row.credentialId,
146 environmentId: row.environmentId,
147 environmentPublicKey: row.environmentPublicKey,
148 })
149 : Option.none();
150 },
151 Effect.mapError((cause) => new EnvironmentCredentialAuthenticatePersistenceError({ cause })),
152 ),
153223
154 revokeForEnvironmentPublicKey: Effect.fn(224 revokeForEnvironmentPublicKey: Effect.fn(
155 "relay.environment_credentials.revoke_for_environment_public_key",225 "relay.environment_credentials.revoke_for_environment_public_key",
156 )(226 )(function* (input) {
157 function* (input) {227 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
158 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });228 const revokedAt = DateTime.formatIso(yield* DateTime.now);
159 const revokedAt = DateTime.formatIso(yield* DateTime.now);229 const rows = yield* db
160 const rows = yield* db230 .update(relayEnvironmentCredentials)
161 .update(relayEnvironmentCredentials)231 .set({
162 .set({232 revokedAt,
163 revokedAt,233 updatedAt: revokedAt,
164 updatedAt: revokedAt,234 })
165 })235 .where(
166 .where(236 and(
167 and(237 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
168 eq(relayEnvironmentCredentials.environmentId, input.environmentId),238 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
169 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),239 isNull(relayEnvironmentCredentials.revokedAt),
170 isNull(relayEnvironmentCredentials.revokedAt),240 notExists(
171 notExists(241 db
172 db242 .select({ userId: relayEnvironmentLinks.userId })
173 .select({ userId: relayEnvironmentLinks.userId })243 .from(relayEnvironmentLinks)
174 .from(relayEnvironmentLinks)244 .where(
175 .where(245 and(
176 and(246 eq(relayEnvironmentLinks.environmentId, input.environmentId),
177 eq(relayEnvironmentLinks.environmentId, input.environmentId),247 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
178 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),248 isNull(relayEnvironmentLinks.revokedAt),
179 isNull(relayEnvironmentLinks.revokedAt),
180 ),
181 ),249 ),
182 ),250 ),
183 ),251 ),
184 )252 ),
185 .returning({253 )
186 credentialId: relayEnvironmentCredentials.credentialId,254 .returning({
187 });255 credentialId: relayEnvironmentCredentials.credentialId,
188 return rows.length > 0;256 })
189 },257 .pipe(
190 Effect.mapError((cause) => new EnvironmentCredentialRevokePersistenceError({ cause })),258 Effect.mapError(
191 ),259 (cause) =>
260 new EnvironmentCredentialRevokePersistenceError({
261 environmentId: input.environmentId,
262 cause,
263 }),
264 ),
265 );
266 return rows.length > 0;
267 }),
192 });268 });
193});269});
194270
infra/relay/src/environments/EnvironmentLinks.test.ts+70
...@@ -8,6 +8,76 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts";...@@ -8,6 +8,76 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts";
8import * as EnvironmentLinks from "./EnvironmentLinks.ts";8import * as EnvironmentLinks from "./EnvironmentLinks.ts";
99
10describe("EnvironmentLinks", () => {10describe("EnvironmentLinks", () => {
11 it.effect("retains link lookup failures with user and environment identity", () => {
12 const cause = new Error("database unavailable");
13 const fakeDb = {
14 select: () => ({
15 from: (table: unknown) => {
16 expect(table).toBe(relayEnvironmentLinks);
17 return {
18 where: () => ({
19 limit: () => Effect.fail(cause),
20 }),
21 };
22 },
23 }),
24 } as unknown as RelayDb.RelayDb["Service"];
25
26 return Effect.gen(function* () {
27 const links = yield* EnvironmentLinks.EnvironmentLinks;
28 const error = yield* Effect.flip(
29 links.getForUser({ userId: "user-1", environmentId: "env-1" }),
30 );
31
32 expect(error).toMatchObject({
33 _tag: "EnvironmentLinkLookupPersistenceError",
34 userId: "user-1",
35 environmentId: "env-1",
36 });
37 expect(error.cause).toBe(cause);
38 }).pipe(
39 Effect.provide(
40 EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
41 ),
42 );
43 });
44
45 it.effect("identifies delivery-user list failures without retaining key material", () => {
46 const cause = new Error("database unavailable");
47 const fakeDb = {
48 select: () => ({
49 from: (table: unknown) => {
50 expect(table).toBe(relayEnvironmentLinks);
51 return {
52 where: () => Effect.fail(cause),
53 };
54 },
55 }),
56 } as unknown as RelayDb.RelayDb["Service"];
57
58 return Effect.gen(function* () {
59 const links = yield* EnvironmentLinks.EnvironmentLinks;
60 const error = yield* Effect.flip(
61 links.listDeliveryUsersForEnvironment({
62 environmentId: "env-1",
63 environmentPublicKey: "sensitive-public-key-material",
64 }),
65 );
66
67 expect(error).toMatchObject({
68 _tag: "EnvironmentLinkUserListPersistenceError",
69 operation: "list-delivery-users",
70 environmentId: "env-1",
71 });
72 expect(error.cause).toBe(cause);
73 expect(error).not.toHaveProperty("environmentPublicKey");
74 }).pipe(
75 Effect.provide(
76 EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
77 ),
78 );
79 });
80
11 it.effect("selects users when either notifications or Live Activities are enabled", () => {81 it.effect("selects users when either notifications or Live Activities are enabled", () => {
12 const whereConditions: Array<unknown> = [];82 const whereConditions: Array<unknown> = [];
13 const fakeDb = {83 const fakeDb = {
infra/relay/src/environments/EnvironmentLinks.ts+148-77
...@@ -26,55 +26,78 @@ export interface AgentAwarenessDeliveryUserRecord {...@@ -26,55 +26,78 @@ export interface AgentAwarenessDeliveryUserRecord {
2626
27export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkUpsertPersistenceError>()(27export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkUpsertPersistenceError>()(
28 "EnvironmentLinkUpsertPersistenceError",28 "EnvironmentLinkUpsertPersistenceError",
29 { cause: Schema.Defect() },29 {
30 userId: Schema.String,
31 environmentId: Schema.String,
32 deviceId: Schema.optionalKey(Schema.String),
33 cause: Schema.Defect(),
34 },
30) {35) {
31 override get message(): string {36 override get message(): string {
32 return "Failed to persist environment link";37 return `Failed to persist environment link for user '${this.userId}', environment '${this.environmentId}'`;
33 }38 }
34}39}
3540
36export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkUserListPersistenceError>()(41export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkUserListPersistenceError>()(
37 "EnvironmentLinkUserListPersistenceError",42 "EnvironmentLinkUserListPersistenceError",
38 { cause: Schema.Defect() },43 {
44 operation: Schema.Literals(["list-users", "list-delivery-users"]),
45 environmentId: Schema.String,
46 cause: Schema.Defect(),
47 },
39) {48) {
40 override get message(): string {49 override get message(): string {
41 return "Failed to list users linked to environment";50 return `Environment link user query '${this.operation}' failed for environment '${this.environmentId}'`;
42 }51 }
43}52}
4453
45export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedErrorClass<EnvironmentPublicKeyListPersistenceError>()(54export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedErrorClass<EnvironmentPublicKeyListPersistenceError>()(
46 "EnvironmentPublicKeyListPersistenceError",55 "EnvironmentPublicKeyListPersistenceError",
47 { cause: Schema.Defect() },56 {
57 environmentId: Schema.String,
58 cause: Schema.Defect(),
59 },
48) {60) {
49 override get message(): string {61 override get message(): string {
50 return "Failed to list environment public keys";62 return `Failed to list public keys for environment '${this.environmentId}'`;
51 }63 }
52}64}
5365
54export class EnvironmentLinkListPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkListPersistenceError>()(66export class EnvironmentLinkListPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkListPersistenceError>()(
55 "EnvironmentLinkListPersistenceError",67 "EnvironmentLinkListPersistenceError",
56 { cause: Schema.Defect() },68 {
69 userId: Schema.String,
70 cause: Schema.Defect(),
71 },
57) {72) {
58 override get message(): string {73 override get message(): string {
59 return "Failed to list environment links";74 return `Failed to list environment links for user '${this.userId}'`;
60 }75 }
61}76}
6277
63export class EnvironmentLinkLookupPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkLookupPersistenceError>()(78export class EnvironmentLinkLookupPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkLookupPersistenceError>()(
64 "EnvironmentLinkLookupPersistenceError",79 "EnvironmentLinkLookupPersistenceError",
65 { cause: Schema.Defect() },80 {
81 userId: Schema.String,
82 environmentId: Schema.String,
83 cause: Schema.Defect(),
84 },
66) {85) {
67 override get message(): string {86 override get message(): string {
68 return "Failed to look up environment link";87 return `Failed to look up environment link for user '${this.userId}', environment '${this.environmentId}'`;
69 }88 }
70}89}
7190
72export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkRevokePersistenceError>()(91export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkRevokePersistenceError>()(
73 "EnvironmentLinkRevokePersistenceError",92 "EnvironmentLinkRevokePersistenceError",
74 { cause: Schema.Defect() },93 {
94 userId: Schema.String,
95 environmentId: Schema.String,
96 cause: Schema.Defect(),
97 },
75) {98) {
76 override get message(): string {99 override get message(): string {
77 return "Failed to revoke environment link";100 return `Failed to revoke environment link for user '${this.userId}', environment '${this.environmentId}'`;
78 }101 }
79}102}
80103
...@@ -142,22 +165,37 @@ const make = Effect.gen(function* () {...@@ -142,22 +165,37 @@ const make = Effect.gen(function* () {
142 const db = yield* RelayDb.RelayDb;165 const db = yield* RelayDb.RelayDb;
143166
144 return EnvironmentLinks.of({167 return EnvironmentLinks.of({
145 upsert: Effect.fn("relay.environment_links.upsert")(168 upsert: Effect.fn("relay.environment_links.upsert")(function* (input) {
146 function* (input) {169 yield* Effect.annotateCurrentSpan({
147 yield* Effect.annotateCurrentSpan({170 "relay.environment_id": input.proof.environmentId,
148 "relay.environment_id": input.proof.environmentId,171 });
149 });172 const now = DateTime.formatIso(yield* DateTime.now);
150 const now = DateTime.formatIso(yield* DateTime.now);173 const { request, proof } = input;
151 const { request, proof } = input;174 const environmentId = proof.environmentId;
152 const environmentId = proof.environmentId;175 const { endpoint } = input;
153 const { endpoint } = input;176 yield* db
154 yield* db177 .insert(relayEnvironmentLinks)
155 .insert(relayEnvironmentLinks)178 .values({
156 .values({179 userId: input.userId,
157 userId: input.userId,180 environmentId,
158 environmentId,181 environmentLabel: proof.descriptor.label,
159 environmentLabel: proof.descriptor.label,182 environmentPublicKey: proof.environmentPublicKey,
183 endpointHttpBaseUrl: endpoint.httpBaseUrl,
184 endpointWsBaseUrl: endpoint.wsBaseUrl,
185 endpointProviderKind: endpoint.providerKind,
186 notificationsEnabled: request.notificationsEnabled,
187 liveActivitiesEnabled: request.liveActivitiesEnabled,
188 managedTunnelsEnabled: request.managedTunnelsEnabled,
189 createdByDeviceId: request.deviceId ?? null,
190 revokedAt: null,
191 createdAt: now,
192 updatedAt: now,
193 })
194 .onConflictDoUpdate({
195 target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId],
196 set: {
160 environmentPublicKey: proof.environmentPublicKey,197 environmentPublicKey: proof.environmentPublicKey,
198 environmentLabel: proof.descriptor.label,
161 endpointHttpBaseUrl: endpoint.httpBaseUrl,199 endpointHttpBaseUrl: endpoint.httpBaseUrl,
162 endpointWsBaseUrl: endpoint.wsBaseUrl,200 endpointWsBaseUrl: endpoint.wsBaseUrl,
163 endpointProviderKind: endpoint.providerKind,201 endpointProviderKind: endpoint.providerKind,
...@@ -166,28 +204,21 @@ const make = Effect.gen(function* () {...@@ -166,28 +204,21 @@ const make = Effect.gen(function* () {
166 managedTunnelsEnabled: request.managedTunnelsEnabled,204 managedTunnelsEnabled: request.managedTunnelsEnabled,
167 createdByDeviceId: request.deviceId ?? null,205 createdByDeviceId: request.deviceId ?? null,
168 revokedAt: null,206 revokedAt: null,
169 createdAt: now,
170 updatedAt: now,207 updatedAt: now,
171 })208 },
172 .onConflictDoUpdate({209 })
173 target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId],210 .pipe(
174 set: {211 Effect.mapError(
175 environmentPublicKey: proof.environmentPublicKey,212 (cause) =>
176 environmentLabel: proof.descriptor.label,213 new EnvironmentLinkUpsertPersistenceError({
177 endpointHttpBaseUrl: endpoint.httpBaseUrl,214 userId: input.userId,
178 endpointWsBaseUrl: endpoint.wsBaseUrl,215 environmentId,
179 endpointProviderKind: endpoint.providerKind,216 ...(request.deviceId === undefined ? {} : { deviceId: request.deviceId }),
180 notificationsEnabled: request.notificationsEnabled,217 cause,
181 liveActivitiesEnabled: request.liveActivitiesEnabled,218 }),
182 managedTunnelsEnabled: request.managedTunnelsEnabled,219 ),
183 createdByDeviceId: request.deviceId ?? null,220 );
184 revokedAt: null,221 }),
185 updatedAt: now,
186 },
187 });
188 },
189 Effect.mapError((cause) => new EnvironmentLinkUpsertPersistenceError({ cause })),
190 ),
191222
192 listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")(223 listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")(
193 function* (input) {224 function* (input) {
...@@ -198,7 +229,14 @@ const make = Effect.gen(function* () {...@@ -198,7 +229,14 @@ const make = Effect.gen(function* () {
198 .where(agentAwarenessDeliveryUserCondition(input.environmentId))229 .where(agentAwarenessDeliveryUserCondition(input.environmentId))
199 .pipe(230 .pipe(
200 Effect.map((rows) => rows.map((row) => row.userId)),231 Effect.map((rows) => rows.map((row) => row.userId)),
201 Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })),232 Effect.mapError(
233 (cause) =>
234 new EnvironmentLinkUserListPersistenceError({
235 operation: "list-users",
236 environmentId: input.environmentId,
237 cause,
238 }),
239 ),
202 );240 );
203 },241 },
204 ),242 ),
...@@ -223,7 +261,14 @@ const make = Effect.gen(function* () {...@@ -223,7 +261,14 @@ const make = Effect.gen(function* () {
223 liveActivitiesEnabled: row.liveActivitiesEnabled,261 liveActivitiesEnabled: row.liveActivitiesEnabled,
224 })),262 })),
225 ),263 ),
226 Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })),264 Effect.mapError(
265 (cause) =>
266 new EnvironmentLinkUserListPersistenceError({
267 operation: "list-delivery-users",
268 environmentId: input.environmentId,
269 cause,
270 }),
271 ),
227 );272 );
228 }),273 }),
229274
...@@ -244,7 +289,13 @@ const make = Effect.gen(function* () {...@@ -244,7 +289,13 @@ const make = Effect.gen(function* () {
244 Effect.map((rows) => [289 Effect.map((rows) => [
245 ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)),290 ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)),
246 ]),291 ]),
247 Effect.mapError((cause) => new EnvironmentPublicKeyListPersistenceError({ cause })),292 Effect.mapError(
293 (cause) =>
294 new EnvironmentPublicKeyListPersistenceError({
295 environmentId: input.environmentId,
296 cause,
297 }),
298 ),
248 );299 );
249 }),300 }),
250301
...@@ -280,7 +331,13 @@ const make = Effect.gen(function* () {...@@ -280,7 +331,13 @@ const make = Effect.gen(function* () {
280 linkedAt: row.createdAt,331 linkedAt: row.createdAt,
281 })),332 })),
282 ),333 ),
283 Effect.mapError((cause) => new EnvironmentLinkListPersistenceError({ cause })),334 Effect.mapError(
335 (cause) =>
336 new EnvironmentLinkListPersistenceError({
337 userId: input.userId,
338 cause,
339 }),
340 ),
284 );341 );
285 }),342 }),
286343
...@@ -328,34 +385,48 @@ const make = Effect.gen(function* () {...@@ -328,34 +385,48 @@ const make = Effect.gen(function* () {
328 }385 }
329 : null;386 : null;
330 }),387 }),
331 Effect.mapError((cause) => new EnvironmentLinkLookupPersistenceError({ cause })),388 Effect.mapError(
389 (cause) =>
390 new EnvironmentLinkLookupPersistenceError({
391 userId: input.userId,
392 environmentId: input.environmentId,
393 cause,
394 }),
395 ),
332 );396 );
333 }),397 }),
334398
335 revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(399 revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(function* (input) {
336 function* (input) {400 yield* Effect.annotateCurrentSpan({
337 yield* Effect.annotateCurrentSpan({401 "relay.environment_id": input.environmentId,
338 "relay.environment_id": input.environmentId,402 });
339 });403 const revokedAt = DateTime.formatIso(yield* DateTime.now);
340 const revokedAt = DateTime.formatIso(yield* DateTime.now);404 const rows = yield* db
341 const rows = yield* db405 .update(relayEnvironmentLinks)
342 .update(relayEnvironmentLinks)406 .set({
343 .set({407 revokedAt,
344 revokedAt,408 updatedAt: revokedAt,
345 updatedAt: revokedAt,409 })
346 })410 .where(
347 .where(411 and(
348 and(412 eq(relayEnvironmentLinks.userId, input.userId),
349 eq(relayEnvironmentLinks.userId, input.userId),413 eq(relayEnvironmentLinks.environmentId, input.environmentId),
350 eq(relayEnvironmentLinks.environmentId, input.environmentId),414 isNull(relayEnvironmentLinks.revokedAt),
351 isNull(relayEnvironmentLinks.revokedAt),415 ),
352 ),416 )
353 )417 .returning({ environmentId: relayEnvironmentLinks.environmentId })
354 .returning({ environmentId: relayEnvironmentLinks.environmentId });418 .pipe(
355 return rows.length > 0;419 Effect.mapError(
356 },420 (cause) =>
357 Effect.mapError((cause) => new EnvironmentLinkRevokePersistenceError({ cause })),421 new EnvironmentLinkRevokePersistenceError({
358 ),422 userId: input.userId,
423 environmentId: input.environmentId,
424 cause,
425 }),
426 ),
427 );
428 return rows.length > 0;
429 }),
359 });430 });
360});431});
361432
infra/relay/src/http/Api.test.ts+1
...@@ -108,6 +108,7 @@ describe("relay client authentication", () => {...@@ -108,6 +108,7 @@ describe("relay client authentication", () => {
108describe("relay environment authentication", () => {108describe("relay environment authentication", () => {
109 it.effect("preserves credential lookup persistence failures as internal errors", () => {109 it.effect("preserves credential lookup persistence failures as internal errors", () => {
110 const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({110 const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({
111 stage: "lookup-credential",
111 cause: "database unavailable",112 cause: "database unavailable",
112 });113 });
113 const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = {114 const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = {
infra/relay/src/http/Api.ts+71-25
...@@ -238,13 +238,12 @@ export const relayEnvironmentAuthLayer = Layer.effect(...@@ -238,13 +238,12 @@ export const relayEnvironmentAuthLayer = Layer.effect(
238 { credential },238 { credential },
239 ) {239 ) {
240 const token = readHttpAuthorizationCredential(credential);240 const token = readHttpAuthorizationCredential(credential);
241 const principal = yield* credentials241 const principal = yield* credentials.authenticate(token).pipe(
242 .authenticate(token)242 Effect.catchTags({
243 .pipe(243 EnvironmentCredentialAuthenticatePersistenceError: () =>
244 Effect.catchTag("EnvironmentCredentialAuthenticatePersistenceError", () =>
245 relayInternalErrorResponse("persistence_failed"),244 relayInternalErrorResponse("persistence_failed"),
246 ),245 }),
247 );246 );
248 if (principal._tag === "None") {247 if (principal._tag === "None") {
249 return yield* relayAuthInvalidError("not_authorized");248 return yield* relayAuthInvalidError("not_authorized");
250 }249 }
...@@ -777,17 +776,72 @@ export const serverApi = HttpApiBuilder.group(...@@ -777,17 +776,72 @@ export const serverApi = HttpApiBuilder.group(
777 reason: "persistence_failed",776 reason: "persistence_failed",
778 traceId,777 traceId,
779 }),778 }),
780 ApnsDeliveryJobQueuePayloadInvalid: mapApnsDeliveryJobInternalError,779 ApnsDeliveryJobQueuePayloadInvalid: (_error, traceId) =>
781 ApnsDeliveryJobLiveActivityAggregateMissing: mapApnsDeliveryJobInternalError,780 new RelayInternalError({
782 ApnsDeliveryJobLiveActivityNotificationUnexpected: mapApnsDeliveryJobInternalError,781 code: "internal_error",
783 ApnsDeliveryJobPushNotificationMissing: mapApnsDeliveryJobInternalError,782 reason: "internal_error",
784 ApnsDeliveryJobPushNotificationAggregateUnexpected: mapApnsDeliveryJobInternalError,783 traceId,
785 ApnsDeliveryJobCreatedAtInvalid: mapApnsDeliveryJobInternalError,784 }),
786 ApnsDeliveryJobExpiresAtInvalid: mapApnsDeliveryJobInternalError,785 ApnsDeliveryJobLiveActivityAggregateMissing: (_error, traceId) =>
787 ApnsDeliveryJobTimeWindowInvalid: mapApnsDeliveryJobInternalError,786 new RelayInternalError({
788 ApnsDeliveryJobTimeWindowTooLong: mapApnsDeliveryJobInternalError,787 code: "internal_error",
789 ApnsDeliveryJobSignatureInvalid: mapApnsDeliveryJobInternalError,788 reason: "internal_error",
790 ApnsDeliveryJobExpired: mapApnsDeliveryJobInternalError,789 traceId,
790 }),
791 ApnsDeliveryJobLiveActivityNotificationUnexpected: (_error, traceId) =>
792 new RelayInternalError({
793 code: "internal_error",
794 reason: "internal_error",
795 traceId,
796 }),
797 ApnsDeliveryJobPushNotificationMissing: (_error, traceId) =>
798 new RelayInternalError({
799 code: "internal_error",
800 reason: "internal_error",
801 traceId,
802 }),
803 ApnsDeliveryJobPushNotificationAggregateUnexpected: (_error, traceId) =>
804 new RelayInternalError({
805 code: "internal_error",
806 reason: "internal_error",
807 traceId,
808 }),
809 ApnsDeliveryJobCreatedAtInvalid: (_error, traceId) =>
810 new RelayInternalError({
811 code: "internal_error",
812 reason: "internal_error",
813 traceId,
814 }),
815 ApnsDeliveryJobExpiresAtInvalid: (_error, traceId) =>
816 new RelayInternalError({
817 code: "internal_error",
818 reason: "internal_error",
819 traceId,
820 }),
821 ApnsDeliveryJobTimeWindowInvalid: (_error, traceId) =>
822 new RelayInternalError({
823 code: "internal_error",
824 reason: "internal_error",
825 traceId,
826 }),
827 ApnsDeliveryJobTimeWindowTooLong: (_error, traceId) =>
828 new RelayInternalError({
829 code: "internal_error",
830 reason: "internal_error",
831 traceId,
832 }),
833 ApnsDeliveryJobSignatureInvalid: (_error, traceId) =>
834 new RelayInternalError({
835 code: "internal_error",
836 reason: "internal_error",
837 traceId,
838 }),
839 ApnsDeliveryJobExpired: (_error, traceId) =>
840 new RelayInternalError({
841 code: "internal_error",
842 reason: "internal_error",
843 traceId,
844 }),
791 ApnsDeliveryJobClaimInFlight: (_error, traceId) =>845 ApnsDeliveryJobClaimInFlight: (_error, traceId) =>
792 new RelayInternalError({846 new RelayInternalError({
793 code: "internal_error",847 code: "internal_error",
...@@ -892,14 +946,6 @@ function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {...@@ -892,14 +946,6 @@ function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {
892 ): Effect.Effect<A, MapRelayCommonApiError<E>, R> => effect.pipe(Effect.catch(mapError));946 ): Effect.Effect<A, MapRelayCommonApiError<E>, R> => effect.pipe(Effect.catch(mapError));
893}947}
894948
895function mapApnsDeliveryJobInternalError(_error: unknown, traceId: string) {
896 return new RelayInternalError({
897 code: "internal_error",
898 reason: "internal_error",
899 traceId,
900 });
901}
902
903type TaggedErrorTag<E> = Extract<E, { readonly _tag: string }>["_tag"];949type TaggedErrorTag<E> = Extract<E, { readonly _tag: string }>["_tag"];
904950
905type MapErrorTagCases<E> = {951type MapErrorTagCases<E> = {