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", () => {
9696 Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
9797 );
9898 });
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 });
99125});
infra/relay/src/auth/DpopProofs.ts+33-8
......@@ -13,11 +13,16 @@ import { relayDpopProofs } from "../persistence/schema.ts";
1313export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass<DpopProofReplayPersistenceError>()(
1414 "DpopProofReplayPersistenceError",
1515 {
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),
1621 cause: Schema.Defect(),
1722 },
1823) {
1924 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}'`;
2126 }
2227}
2328
......@@ -58,10 +63,21 @@ const make = Effect.gen(function* () {
5863 createdAt,
5964 })
6065 .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 );
6279 return inserted.length > 0;
6380 },
64 Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })),
6581 );
6682
6783 const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn(
......@@ -114,11 +130,20 @@ const make = Effect.gen(function* () {
114130 const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () {
115131 const now = DateTime.formatIso(yield* DateTime.now);
116132 yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now });
117 yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now));
118 }).pipe(
119 Effect.withSpan("relay.dpop_proofs.prune_expired"),
120 Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })),
121 );
133 yield* db
134 .delete(relayDpopProofs)
135 .where(lt(relayDpopProofs.expiresAt, now))
136 .pipe(
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
123148 return DpopProofReplay.of({
124149 verifyAndConsume,
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts+11-3
......@@ -163,7 +163,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
163163 iat: Math.floor(now.epochMilliseconds / 1_000),
164164 jti: "proof-persistence-failure",
165165 });
166 const cause = "database unavailable";
166 const cause = { _tag: "DatabaseUnavailable" } as const;
167167
168168 return Effect.gen(function* () {
169169 const replay = yield* DpopProofs.DpopProofReplay;
......@@ -177,8 +177,16 @@ describe("DpopProofReplay.verifyAndConsume", () => {
177177 }),
178178 );
179179
180 expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause }));
181 }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause }))));
180 expect(error).toMatchObject({
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))));
182190 });
183191
184192 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";
99import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";
1010
1111describe("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
1294 it.effect(
1395 "creates opaque credentials and revokes only older credentials for the same key",
1496 () => {
infra/relay/src/environments/EnvironmentCredentials.ts+172-96
......@@ -13,28 +13,44 @@ import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persisten
1313
1414export class EnvironmentCredentialCreatePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialCreatePersistenceError>()(
1515 "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 },
1727) {
1828 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}'`}`;
2030 }
2131}
2232
2333export class EnvironmentCredentialAuthenticatePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialAuthenticatePersistenceError>()(
2434 "EnvironmentCredentialAuthenticatePersistenceError",
25 { cause: Schema.Defect() },
35 {
36 stage: Schema.Literals(["hash-token", "lookup-credential"]),
37 cause: Schema.Defect(),
38 },
2639) {
2740 override get message(): string {
28 return "Failed to authenticate environment credential";
41 return `Environment credential authentication failed during '${this.stage}'`;
2942 }
3043}
3144
3245export class EnvironmentCredentialRevokePersistenceError extends Schema.TaggedErrorClass<EnvironmentCredentialRevokePersistenceError>()(
3346 "EnvironmentCredentialRevokePersistenceError",
34 { cause: Schema.Defect() },
47 {
48 environmentId: Schema.String,
49 cause: Schema.Defect(),
50 },
3551) {
3652 override get message(): string {
37 return "Failed to revoke environment credential";
53 return `Failed to revoke credentials for environment '${this.environmentId}'`;
3854 }
3955}
4056
......@@ -85,13 +101,33 @@ const make = Effect.gen(function* () {
85101 });
86102
87103 return EnvironmentCredentials.of({
88 create: Effect.fn("relay.environment_credentials.create")(
89 function* (input) {
90 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
91 const credential = yield* makeCredential();
92 const credentialHash = yield* hashToken(credential.token);
93 const now = DateTime.formatIso(yield* DateTime.now);
94 yield* db.insert(relayEnvironmentCredentials).values({
104 create: Effect.fn("relay.environment_credentials.create")(function* (input) {
105 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
106 const credential = yield* makeCredential().pipe(
107 Effect.mapError(
108 (cause) =>
109 new EnvironmentCredentialCreatePersistenceError({
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({
95131 credentialId: credential.credentialId,
96132 environmentId: input.environmentId,
97133 environmentPublicKey: input.environmentPublicKey,
......@@ -99,96 +135,136 @@ const make = Effect.gen(function* () {
99135 revokedAt: null,
100136 createdAt: now,
101137 updatedAt: now,
102 });
103 yield* db
104 .update(relayEnvironmentCredentials)
105 .set({
106 revokedAt: now,
107 updatedAt: now,
108 })
109 .where(
110 and(
111 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
112 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
113 ne(relayEnvironmentCredentials.credentialId, credential.credentialId),
114 isNull(relayEnvironmentCredentials.revokedAt),
115 ),
116 );
117 return credential.token;
118 },
119 Effect.mapError((cause) => new EnvironmentCredentialCreatePersistenceError({ cause })),
120 ),
138 })
139 .pipe(
140 Effect.mapError(
141 (cause) =>
142 new EnvironmentCredentialCreatePersistenceError({
143 stage: "insert-credential",
144 environmentId: input.environmentId,
145 credentialId: credential.credentialId,
146 cause,
147 }),
148 ),
149 );
150 yield* db
151 .update(relayEnvironmentCredentials)
152 .set({
153 revokedAt: now,
154 updatedAt: now,
155 })
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")(
123 function* (token) {
124 const credentialHash = yield* hashToken(token);
125 const rows = yield* db
126 .select({
127 credentialId: relayEnvironmentCredentials.credentialId,
128 environmentId: relayEnvironmentCredentials.environmentId,
129 environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey,
178 authenticate: Effect.fn("relay.environment_credentials.authenticate")(function* (token) {
179 const credentialHash = yield* hashToken(token).pipe(
180 Effect.mapError(
181 (cause) =>
182 new EnvironmentCredentialAuthenticatePersistenceError({
183 stage: "hash-token",
184 cause,
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,
130220 })
131 .from(relayEnvironmentCredentials)
132 .where(
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 ),
221 : Option.none();
222 }),
153223
154224 revokeForEnvironmentPublicKey: Effect.fn(
155225 "relay.environment_credentials.revoke_for_environment_public_key",
156 )(
157 function* (input) {
158 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
159 const revokedAt = DateTime.formatIso(yield* DateTime.now);
160 const rows = yield* db
161 .update(relayEnvironmentCredentials)
162 .set({
163 revokedAt,
164 updatedAt: revokedAt,
165 })
166 .where(
167 and(
168 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
169 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
170 isNull(relayEnvironmentCredentials.revokedAt),
171 notExists(
172 db
173 .select({ userId: relayEnvironmentLinks.userId })
174 .from(relayEnvironmentLinks)
175 .where(
176 and(
177 eq(relayEnvironmentLinks.environmentId, input.environmentId),
178 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
179 isNull(relayEnvironmentLinks.revokedAt),
180 ),
226 )(function* (input) {
227 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
228 const revokedAt = DateTime.formatIso(yield* DateTime.now);
229 const rows = yield* db
230 .update(relayEnvironmentCredentials)
231 .set({
232 revokedAt,
233 updatedAt: revokedAt,
234 })
235 .where(
236 and(
237 eq(relayEnvironmentCredentials.environmentId, input.environmentId),
238 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
239 isNull(relayEnvironmentCredentials.revokedAt),
240 notExists(
241 db
242 .select({ userId: relayEnvironmentLinks.userId })
243 .from(relayEnvironmentLinks)
244 .where(
245 and(
246 eq(relayEnvironmentLinks.environmentId, input.environmentId),
247 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
248 isNull(relayEnvironmentLinks.revokedAt),
181249 ),
182 ),
250 ),
183251 ),
184 )
185 .returning({
186 credentialId: relayEnvironmentCredentials.credentialId,
187 });
188 return rows.length > 0;
189 },
190 Effect.mapError((cause) => new EnvironmentCredentialRevokePersistenceError({ cause })),
191 ),
252 ),
253 )
254 .returning({
255 credentialId: relayEnvironmentCredentials.credentialId,
256 })
257 .pipe(
258 Effect.mapError(
259 (cause) =>
260 new EnvironmentCredentialRevokePersistenceError({
261 environmentId: input.environmentId,
262 cause,
263 }),
264 ),
265 );
266 return rows.length > 0;
267 }),
192268 });
193269});
194270
infra/relay/src/environments/EnvironmentLinks.test.ts+70
......@@ -8,6 +8,76 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts";
88import * as EnvironmentLinks from "./EnvironmentLinks.ts";
99
1010describe("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
1181 it.effect("selects users when either notifications or Live Activities are enabled", () => {
1282 const whereConditions: Array<unknown> = [];
1383 const fakeDb = {
infra/relay/src/environments/EnvironmentLinks.ts+148-77
......@@ -26,55 +26,78 @@ export interface AgentAwarenessDeliveryUserRecord {
2626
2727export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkUpsertPersistenceError>()(
2828 "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 },
3035) {
3136 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}'`;
3338 }
3439}
3540
3641export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkUserListPersistenceError>()(
3742 "EnvironmentLinkUserListPersistenceError",
38 { cause: Schema.Defect() },
43 {
44 operation: Schema.Literals(["list-users", "list-delivery-users"]),
45 environmentId: Schema.String,
46 cause: Schema.Defect(),
47 },
3948) {
4049 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}'`;
4251 }
4352}
4453
4554export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedErrorClass<EnvironmentPublicKeyListPersistenceError>()(
4655 "EnvironmentPublicKeyListPersistenceError",
47 { cause: Schema.Defect() },
56 {
57 environmentId: Schema.String,
58 cause: Schema.Defect(),
59 },
4860) {
4961 override get message(): string {
50 return "Failed to list environment public keys";
62 return `Failed to list public keys for environment '${this.environmentId}'`;
5163 }
5264}
5365
5466export class EnvironmentLinkListPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkListPersistenceError>()(
5567 "EnvironmentLinkListPersistenceError",
56 { cause: Schema.Defect() },
68 {
69 userId: Schema.String,
70 cause: Schema.Defect(),
71 },
5772) {
5873 override get message(): string {
59 return "Failed to list environment links";
74 return `Failed to list environment links for user '${this.userId}'`;
6075 }
6176}
6277
6378export class EnvironmentLinkLookupPersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkLookupPersistenceError>()(
6479 "EnvironmentLinkLookupPersistenceError",
65 { cause: Schema.Defect() },
80 {
81 userId: Schema.String,
82 environmentId: Schema.String,
83 cause: Schema.Defect(),
84 },
6685) {
6786 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}'`;
6988 }
7089}
7190
7291export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedErrorClass<EnvironmentLinkRevokePersistenceError>()(
7392 "EnvironmentLinkRevokePersistenceError",
74 { cause: Schema.Defect() },
93 {
94 userId: Schema.String,
95 environmentId: Schema.String,
96 cause: Schema.Defect(),
97 },
7598) {
7699 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}'`;
78101 }
79102}
80103
......@@ -142,22 +165,37 @@ const make = Effect.gen(function* () {
142165 const db = yield* RelayDb.RelayDb;
143166
144167 return EnvironmentLinks.of({
145 upsert: Effect.fn("relay.environment_links.upsert")(
146 function* (input) {
147 yield* Effect.annotateCurrentSpan({
148 "relay.environment_id": input.proof.environmentId,
149 });
150 const now = DateTime.formatIso(yield* DateTime.now);
151 const { request, proof } = input;
152 const environmentId = proof.environmentId;
153 const { endpoint } = input;
154 yield* db
155 .insert(relayEnvironmentLinks)
156 .values({
157 userId: input.userId,
158 environmentId,
159 environmentLabel: proof.descriptor.label,
168 upsert: Effect.fn("relay.environment_links.upsert")(function* (input) {
169 yield* Effect.annotateCurrentSpan({
170 "relay.environment_id": input.proof.environmentId,
171 });
172 const now = DateTime.formatIso(yield* DateTime.now);
173 const { request, proof } = input;
174 const environmentId = proof.environmentId;
175 const { endpoint } = input;
176 yield* db
177 .insert(relayEnvironmentLinks)
178 .values({
179 userId: input.userId,
180 environmentId,
181 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: {
160197 environmentPublicKey: proof.environmentPublicKey,
198 environmentLabel: proof.descriptor.label,
161199 endpointHttpBaseUrl: endpoint.httpBaseUrl,
162200 endpointWsBaseUrl: endpoint.wsBaseUrl,
163201 endpointProviderKind: endpoint.providerKind,
......@@ -166,28 +204,21 @@ const make = Effect.gen(function* () {
166204 managedTunnelsEnabled: request.managedTunnelsEnabled,
167205 createdByDeviceId: request.deviceId ?? null,
168206 revokedAt: null,
169 createdAt: now,
170207 updatedAt: now,
171 })
172 .onConflictDoUpdate({
173 target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId],
174 set: {
175 environmentPublicKey: proof.environmentPublicKey,
176 environmentLabel: proof.descriptor.label,
177 endpointHttpBaseUrl: endpoint.httpBaseUrl,
178 endpointWsBaseUrl: endpoint.wsBaseUrl,
179 endpointProviderKind: endpoint.providerKind,
180 notificationsEnabled: request.notificationsEnabled,
181 liveActivitiesEnabled: request.liveActivitiesEnabled,
182 managedTunnelsEnabled: request.managedTunnelsEnabled,
183 createdByDeviceId: request.deviceId ?? null,
184 revokedAt: null,
185 updatedAt: now,
186 },
187 });
188 },
189 Effect.mapError((cause) => new EnvironmentLinkUpsertPersistenceError({ cause })),
190 ),
208 },
209 })
210 .pipe(
211 Effect.mapError(
212 (cause) =>
213 new EnvironmentLinkUpsertPersistenceError({
214 userId: input.userId,
215 environmentId,
216 ...(request.deviceId === undefined ? {} : { deviceId: request.deviceId }),
217 cause,
218 }),
219 ),
220 );
221 }),
191222
192223 listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")(
193224 function* (input) {
......@@ -198,7 +229,14 @@ const make = Effect.gen(function* () {
198229 .where(agentAwarenessDeliveryUserCondition(input.environmentId))
199230 .pipe(
200231 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 ),
202240 );
203241 },
204242 ),
......@@ -223,7 +261,14 @@ const make = Effect.gen(function* () {
223261 liveActivitiesEnabled: row.liveActivitiesEnabled,
224262 })),
225263 ),
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 ),
227272 );
228273 }),
229274
......@@ -244,7 +289,13 @@ const make = Effect.gen(function* () {
244289 Effect.map((rows) => [
245290 ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)),
246291 ]),
247 Effect.mapError((cause) => new EnvironmentPublicKeyListPersistenceError({ cause })),
292 Effect.mapError(
293 (cause) =>
294 new EnvironmentPublicKeyListPersistenceError({
295 environmentId: input.environmentId,
296 cause,
297 }),
298 ),
248299 );
249300 }),
250301
......@@ -280,7 +331,13 @@ const make = Effect.gen(function* () {
280331 linkedAt: row.createdAt,
281332 })),
282333 ),
283 Effect.mapError((cause) => new EnvironmentLinkListPersistenceError({ cause })),
334 Effect.mapError(
335 (cause) =>
336 new EnvironmentLinkListPersistenceError({
337 userId: input.userId,
338 cause,
339 }),
340 ),
284341 );
285342 }),
286343
......@@ -328,34 +385,48 @@ const make = Effect.gen(function* () {
328385 }
329386 : null;
330387 }),
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 ),
332396 );
333397 }),
334398
335 revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(
336 function* (input) {
337 yield* Effect.annotateCurrentSpan({
338 "relay.environment_id": input.environmentId,
339 });
340 const revokedAt = DateTime.formatIso(yield* DateTime.now);
341 const rows = yield* db
342 .update(relayEnvironmentLinks)
343 .set({
344 revokedAt,
345 updatedAt: revokedAt,
346 })
347 .where(
348 and(
349 eq(relayEnvironmentLinks.userId, input.userId),
350 eq(relayEnvironmentLinks.environmentId, input.environmentId),
351 isNull(relayEnvironmentLinks.revokedAt),
352 ),
353 )
354 .returning({ environmentId: relayEnvironmentLinks.environmentId });
355 return rows.length > 0;
356 },
357 Effect.mapError((cause) => new EnvironmentLinkRevokePersistenceError({ cause })),
358 ),
399 revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(function* (input) {
400 yield* Effect.annotateCurrentSpan({
401 "relay.environment_id": input.environmentId,
402 });
403 const revokedAt = DateTime.formatIso(yield* DateTime.now);
404 const rows = yield* db
405 .update(relayEnvironmentLinks)
406 .set({
407 revokedAt,
408 updatedAt: revokedAt,
409 })
410 .where(
411 and(
412 eq(relayEnvironmentLinks.userId, input.userId),
413 eq(relayEnvironmentLinks.environmentId, input.environmentId),
414 isNull(relayEnvironmentLinks.revokedAt),
415 ),
416 )
417 .returning({ environmentId: relayEnvironmentLinks.environmentId })
418 .pipe(
419 Effect.mapError(
420 (cause) =>
421 new EnvironmentLinkRevokePersistenceError({
422 userId: input.userId,
423 environmentId: input.environmentId,
424 cause,
425 }),
426 ),
427 );
428 return rows.length > 0;
429 }),
359430 });
360431});
361432
infra/relay/src/http/Api.test.ts+1
......@@ -108,6 +108,7 @@ describe("relay client authentication", () => {
108108describe("relay environment authentication", () => {
109109 it.effect("preserves credential lookup persistence failures as internal errors", () => {
110110 const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({
111 stage: "lookup-credential",
111112 cause: "database unavailable",
112113 });
113114 const credentials: EnvironmentCredentials.EnvironmentCredentials["Service"] = {
infra/relay/src/http/Api.ts+71-25
......@@ -238,13 +238,12 @@ export const relayEnvironmentAuthLayer = Layer.effect(
238238 { credential },
239239 ) {
240240 const token = readHttpAuthorizationCredential(credential);
241 const principal = yield* credentials
242 .authenticate(token)
243 .pipe(
244 Effect.catchTag("EnvironmentCredentialAuthenticatePersistenceError", () =>
241 const principal = yield* credentials.authenticate(token).pipe(
242 Effect.catchTags({
243 EnvironmentCredentialAuthenticatePersistenceError: () =>
245244 relayInternalErrorResponse("persistence_failed"),
246 ),
247 );
245 }),
246 );
248247 if (principal._tag === "None") {
249248 return yield* relayAuthInvalidError("not_authorized");
250249 }
......@@ -777,17 +776,72 @@ export const serverApi = HttpApiBuilder.group(
777776 reason: "persistence_failed",
778777 traceId,
779778 }),
780 ApnsDeliveryJobQueuePayloadInvalid: mapApnsDeliveryJobInternalError,
781 ApnsDeliveryJobLiveActivityAggregateMissing: mapApnsDeliveryJobInternalError,
782 ApnsDeliveryJobLiveActivityNotificationUnexpected: mapApnsDeliveryJobInternalError,
783 ApnsDeliveryJobPushNotificationMissing: mapApnsDeliveryJobInternalError,
784 ApnsDeliveryJobPushNotificationAggregateUnexpected: mapApnsDeliveryJobInternalError,
785 ApnsDeliveryJobCreatedAtInvalid: mapApnsDeliveryJobInternalError,
786 ApnsDeliveryJobExpiresAtInvalid: mapApnsDeliveryJobInternalError,
787 ApnsDeliveryJobTimeWindowInvalid: mapApnsDeliveryJobInternalError,
788 ApnsDeliveryJobTimeWindowTooLong: mapApnsDeliveryJobInternalError,
789 ApnsDeliveryJobSignatureInvalid: mapApnsDeliveryJobInternalError,
790 ApnsDeliveryJobExpired: mapApnsDeliveryJobInternalError,
779 ApnsDeliveryJobQueuePayloadInvalid: (_error, traceId) =>
780 new RelayInternalError({
781 code: "internal_error",
782 reason: "internal_error",
783 traceId,
784 }),
785 ApnsDeliveryJobLiveActivityAggregateMissing: (_error, traceId) =>
786 new RelayInternalError({
787 code: "internal_error",
788 reason: "internal_error",
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 }),
791845 ApnsDeliveryJobClaimInFlight: (_error, traceId) =>
792846 new RelayInternalError({
793847 code: "internal_error",
......@@ -892,14 +946,6 @@ function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) {
892946 ): Effect.Effect<A, MapRelayCommonApiError<E>, R> => effect.pipe(Effect.catch(mapError));
893947}
894948
895function mapApnsDeliveryJobInternalError(_error: unknown, traceId: string) {
896 return new RelayInternalError({
897 code: "internal_error",
898 reason: "internal_error",
899 traceId,
900 });
901}
902
903949type TaggedErrorTag<E> = Extract<E, { readonly _tag: string }>["_tag"];
904950
905951type MapErrorTagCases<E> = {