@remit/backend 0.0.34 → 0.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,165 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { UpdateFilterInput } from "@remit/data-ports";
4
+ import { FilterScope, FilterState } from "@remit/domain-enums";
5
+ import {
6
+ pickFilterUpdate,
7
+ rejectAnchorMutation,
8
+ resolveFilterScopeExpiry,
9
+ } from "./filter.js";
10
+
11
+ describe("pickFilterUpdate", () => {
12
+ it("carries scope and expiresAt through, alongside the predicate/action fields", () => {
13
+ const patch = pickFilterUpdate({
14
+ scope: FilterScope.Temporary,
15
+ expiresAt: "2027-01-01T00:00:00+00:00",
16
+ matchOperator: "Or",
17
+ });
18
+ assert.equal(patch.scope, FilterScope.Temporary);
19
+ assert.equal(patch.expiresAt, "2027-01-01T00:00:00+00:00");
20
+ assert.equal(patch.matchOperator, "Or");
21
+ });
22
+
23
+ it("leaves scope/expiresAt absent when the body doesn't touch them", () => {
24
+ const patch = pickFilterUpdate({ name: "Receipts" });
25
+ assert.equal("scope" in patch, false);
26
+ assert.equal("expiresAt" in patch, false);
27
+ });
28
+
29
+ it("drops any server-derived field smuggled into the body", () => {
30
+ const raw: Record<string, unknown> = {
31
+ name: "Receipts",
32
+ ttl: 123,
33
+ state: "Expired",
34
+ hasAnchor: true,
35
+ ruleChangedAt: 999,
36
+ filterId: "sneaky",
37
+ };
38
+ const patch = pickFilterUpdate(raw as Partial<UpdateFilterInput>);
39
+ assert.deepEqual(patch, { name: "Receipts" });
40
+ });
41
+ });
42
+
43
+ describe("rejectAnchorMutation", () => {
44
+ it("throws a 400 when the body carries anchorMessageId", () => {
45
+ assert.throws(
46
+ () => rejectAnchorMutation({ anchorMessageId: "msg-1" }),
47
+ (error: unknown) => {
48
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
49
+ assert.match(
50
+ (error as Error).message,
51
+ /anchor can't change after creation/,
52
+ );
53
+ return true;
54
+ },
55
+ );
56
+ });
57
+
58
+ it("does not throw for an ordinary predicate/action/scope patch", () => {
59
+ assert.doesNotThrow(() =>
60
+ rejectAnchorMutation({
61
+ name: "Receipts",
62
+ scope: "Temporary",
63
+ expiresAt: "2027-01-01T00:00:00+00:00",
64
+ }),
65
+ );
66
+ });
67
+ });
68
+
69
+ describe("resolveFilterScopeExpiry (reader #266)", () => {
70
+ it("moves Standing to Temporary given a future expiresAt", () => {
71
+ const resolved = resolveFilterScopeExpiry(
72
+ { scope: FilterScope.Standing, expiresAt: undefined },
73
+ { scope: FilterScope.Temporary, expiresAt: "2099-01-01T00:00:00+00:00" },
74
+ );
75
+ assert.equal(resolved.scope, FilterScope.Temporary);
76
+ assert.equal(resolved.expiresAt, "2099-01-01T00:00:00+00:00");
77
+ assert.equal(resolved.state, FilterState.Active);
78
+ assert.ok(resolved.ttl && resolved.ttl > 0);
79
+ });
80
+
81
+ it("rejects moving to Temporary without an expiresAt", () => {
82
+ assert.throws(
83
+ () =>
84
+ resolveFilterScopeExpiry(
85
+ { scope: FilterScope.Standing, expiresAt: undefined },
86
+ { scope: FilterScope.Temporary },
87
+ ),
88
+ (error: unknown) => {
89
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
90
+ assert.match((error as Error).message, /needs expiresAt/);
91
+ return true;
92
+ },
93
+ );
94
+ });
95
+
96
+ it("rejects an expiresAt patch that isn't paired with a Temporary scope", () => {
97
+ assert.throws(
98
+ () =>
99
+ resolveFilterScopeExpiry(
100
+ { scope: FilterScope.Standing, expiresAt: undefined },
101
+ { expiresAt: "2099-01-01T00:00:00+00:00" },
102
+ ),
103
+ (error: unknown) => {
104
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
105
+ assert.match((error as Error).message, /only applies to a Temporary/);
106
+ return true;
107
+ },
108
+ );
109
+ });
110
+
111
+ it("clears expiresAt/ttl and reports Active when moving Temporary to Standing", () => {
112
+ const resolved = resolveFilterScopeExpiry(
113
+ { scope: FilterScope.Temporary, expiresAt: "2020-01-01T00:00:00+00:00" },
114
+ { scope: FilterScope.Standing },
115
+ );
116
+ assert.equal(resolved.scope, FilterScope.Standing);
117
+ assert.equal(resolved.expiresAt, undefined);
118
+ assert.equal(resolved.ttl, undefined);
119
+ assert.equal(resolved.state, FilterState.Active);
120
+ });
121
+
122
+ it("recomputes ttl for an expiresAt-only change on an already-Temporary filter", () => {
123
+ const resolved = resolveFilterScopeExpiry(
124
+ { scope: FilterScope.Temporary, expiresAt: "2026-08-01T00:00:00+00:00" },
125
+ { expiresAt: "2099-06-01T00:00:00+00:00" },
126
+ );
127
+ assert.equal(resolved.scope, FilterScope.Temporary);
128
+ assert.equal(resolved.expiresAt, "2099-06-01T00:00:00+00:00");
129
+ assert.equal(
130
+ resolved.ttl,
131
+ Math.floor(new Date("2099-06-01T00:00:00+00:00").getTime() / 1000),
132
+ );
133
+ assert.equal(resolved.state, FilterState.Active);
134
+ });
135
+
136
+ it("reactivates a lapsed filter extended into the future", () => {
137
+ const resolved = resolveFilterScopeExpiry(
138
+ { scope: FilterScope.Temporary, expiresAt: "2020-01-01T00:00:00+00:00" },
139
+ { expiresAt: "2099-01-01T00:00:00+00:00" },
140
+ );
141
+ assert.equal(resolved.state, FilterState.Active);
142
+ });
143
+
144
+ it("reads Expired immediately when the new expiresAt is already in the past", () => {
145
+ const resolved = resolveFilterScopeExpiry(
146
+ { scope: FilterScope.Temporary, expiresAt: "2099-01-01T00:00:00+00:00" },
147
+ { expiresAt: "2020-01-01T00:00:00+00:00" },
148
+ );
149
+ assert.equal(resolved.state, FilterState.Expired);
150
+ });
151
+
152
+ it("rejects an unparseable expiresAt", () => {
153
+ assert.throws(
154
+ () =>
155
+ resolveFilterScopeExpiry(
156
+ { scope: FilterScope.Standing, expiresAt: undefined },
157
+ { scope: FilterScope.Temporary, expiresAt: "not-a-date" },
158
+ ),
159
+ (error: unknown) => {
160
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
161
+ return true;
162
+ },
163
+ );
164
+ });
165
+ });
@@ -1,11 +1,10 @@
1
1
  import type {
2
2
  CreateFilterInput,
3
3
  FilterResponse,
4
- UpdateFilterInput,
5
4
  } from "@remit/api-openapi-types";
6
- import type { FilterItem } from "@remit/data-ports";
7
- import { ClientError } from "@remit/data-ports/errors";
8
- import { FilterScope } from "@remit/domain-enums";
5
+ import type { FilterItem, UpdateFilterInput } from "@remit/data-ports";
6
+ import { BadRequestError } from "@remit/data-ports/errors";
7
+ import { FilterScope, FilterState } from "@remit/domain-enums";
9
8
  import type { AnchorPayload } from "@remit/search-service";
10
9
  import type { APIGatewayProxyEvent } from "aws-lambda";
11
10
  import { getAccountConfigIdFromEvent } from "../auth.js";
@@ -40,7 +39,7 @@ export interface FilterCrudDeps {
40
39
  update(
41
40
  accountConfigId: string,
42
41
  filterId: string,
43
- input: Partial<UpdateFilterInput>,
42
+ input: UpdateFilterInput,
44
43
  ): Promise<FilterItem>;
45
44
  delete(accountConfigId: string, filterId: string): Promise<void>;
46
45
  refreshExpiry(item: FilterItem): Promise<FilterItem>;
@@ -78,24 +77,40 @@ export const deriveFilterTtl = (
78
77
  if (scope !== FilterScope.Temporary || !expiresAt) return undefined;
79
78
  const ms = new Date(expiresAt).getTime();
80
79
  if (Number.isNaN(ms)) {
81
- throw new ClientError(`Invalid expiresAt: ${expiresAt}`);
80
+ throw new BadRequestError(`Invalid expiresAt: ${expiresAt}`);
82
81
  }
83
82
  return Math.floor(ms / 1000);
84
83
  };
85
84
 
86
85
  /**
87
- * Reduce a PATCH body to the fields a filter update may set (RFC 034). Preserves
88
- * absence: a key not present in the body is not present in the patch, so a
89
- * name-only rename yields `{ name }` and never touches a predicate/action field
90
- * — the service's `changesPredicateOrAction` guard then leaves `ruleChangedAt`
91
- * untouched (Decision 3.2). Any field outside this set — most notably a
92
- * server-derived `state`/`ruleChangedAt` smuggled into the bodyis dropped.
86
+ * Reduce a PATCH body to the fields a filter update may set (RFC 034, reader
87
+ * #266). Preserves absence: a key not present in the body is not present in
88
+ * the patch, so a name-only rename yields `{ name }` and never touches a
89
+ * predicate/action/scope/expiresAt field — the service's
90
+ * `changesRuleAssertion` guard then leaves `ruleChangedAt` untouched (Decision
91
+ * 3.2). Any field outside this setmost notably a server-derived
92
+ * `state`/`ttl`/`hasAnchor`/`ruleChangedAt` smuggled into the body — is
93
+ * dropped. `scope`/`expiresAt` land here as the caller sent them; a patch that
94
+ * touches either still needs `resolveFilterScopeExpiry` to merge them against
95
+ * the stored row and derive `ttl`/`state` before this reaches the repo.
96
+ *
97
+ * Typed against the internal `@remit/data-ports` `UpdateFilterInput`, not the
98
+ * generated `@remit/api-openapi-types` one: that package publishes separately
99
+ * from this repo, so a PR that both widens the TypeSpec model and reads the
100
+ * new field in the same change would typecheck in-tree (fresh local codegen)
101
+ * but fail `check-consumer-typecheck`/`release:dry-run`, which resolve
102
+ * generated `@remit/*` packages off the registry, not the local `build/`
103
+ * output. The data-ports shape carries every field this reads regardless —
104
+ * it derives from the full `Filter` row, which has never gated `scope` or
105
+ * `expiresAt` behind an API-visibility distinction.
93
106
  */
94
107
  export const pickFilterUpdate = (
95
108
  body: Partial<UpdateFilterInput>,
96
109
  ): Partial<UpdateFilterInput> => {
97
110
  const patch: Partial<UpdateFilterInput> = {};
98
111
  if (Object.hasOwn(body, "name")) patch.name = body.name;
112
+ if (Object.hasOwn(body, "scope")) patch.scope = body.scope;
113
+ if (Object.hasOwn(body, "expiresAt")) patch.expiresAt = body.expiresAt;
99
114
  if (Object.hasOwn(body, "matchOperator")) {
100
115
  patch.matchOperator = body.matchOperator;
101
116
  }
@@ -111,6 +126,91 @@ export const pickFilterUpdate = (
111
126
  return patch;
112
127
  };
113
128
 
129
+ /**
130
+ * A filter's semantic anchor is set once, from `anchorMessageId`, only on
131
+ * `CreateFilterInput` (RFC 034 Decision 2) — the generated `UpdateFilterInput`
132
+ * carries no anchor field at all, so a well-behaved client cannot express this.
133
+ * This only catches a caller handing the raw PATCH body an anchor field
134
+ * anyway, and rejects it loudly rather than silently dropping it (reader
135
+ * #266): repointing an anchor would silently change what a saved filter
136
+ * matches with nothing visible changing, and that deserves a new filter
137
+ * instead — scope and expiry cover the rest of what changed here.
138
+ */
139
+ const ANCHOR_MUTATION_FIELDS = ["anchorMessageId"] as const;
140
+
141
+ export const rejectAnchorMutation = (body: Record<string, unknown>): void => {
142
+ const attempted = ANCHOR_MUTATION_FIELDS.find((field) =>
143
+ Object.hasOwn(body, field),
144
+ );
145
+ if (!attempted) return;
146
+ throw new BadRequestError(
147
+ "A filter's semantic anchor can't change after creation — create a new filter instead.",
148
+ );
149
+ };
150
+
151
+ export interface ResolvedScopeExpiry {
152
+ scope: FilterItem["scope"];
153
+ expiresAt?: string;
154
+ ttl?: number;
155
+ state: FilterItem["state"];
156
+ }
157
+
158
+ /**
159
+ * Merge a PATCH body's `scope`/`expiresAt` against the filter's stored values
160
+ * and resolve the full set the row must land on (reader #266). Only called
161
+ * when the patch touches `scope` or `expiresAt` — one that touches neither
162
+ * leaves both, and `ttl`/`state`, untouched exactly as before this ticket.
163
+ *
164
+ * A `Temporary` result always carries a defined `expiresAt` — moving to
165
+ * `Temporary` without one is rejected, matching the Filter model's invariant
166
+ * that a Temporary filter always has an expiry (RFC 034 Decision 1.1). Moving
167
+ * to `Standing` clears `expiresAt`/`ttl`, matching the model's invariant that
168
+ * both stay absent outside `Temporary` (RFC 034 Decision 1.4) — the reserved
169
+ * `ttl` attribute must never linger once a filter is no longer temporary.
170
+ *
171
+ * `state` is recomputed the same comparison `refreshExpiry` runs, so a filter
172
+ * extended past a lapsed `expiresAt` (or switched to `Standing`) reads Active
173
+ * immediately — the index-time worker's `byAccountAndState` query needs that
174
+ * now, not on whatever read happens to touch the row next.
175
+ */
176
+ export const resolveFilterScopeExpiry = (
177
+ current: Pick<FilterItem, "scope" | "expiresAt">,
178
+ patch: Partial<UpdateFilterInput>,
179
+ ): ResolvedScopeExpiry => {
180
+ const scope = patch.scope ?? current.scope;
181
+
182
+ if (scope !== FilterScope.Temporary) {
183
+ if (Object.hasOwn(patch, "expiresAt") && patch.expiresAt) {
184
+ throw new BadRequestError(
185
+ "expiresAt only applies to a Temporary filter — switch scope to Temporary to set one.",
186
+ );
187
+ }
188
+ return {
189
+ scope,
190
+ expiresAt: undefined,
191
+ ttl: undefined,
192
+ state: FilterState.Active,
193
+ };
194
+ }
195
+
196
+ const expiresAt = Object.hasOwn(patch, "expiresAt")
197
+ ? patch.expiresAt
198
+ : current.expiresAt;
199
+ if (!expiresAt) {
200
+ throw new BadRequestError(
201
+ "A Temporary filter needs expiresAt — pick a date, or switch scope to Standing.",
202
+ );
203
+ }
204
+
205
+ const ttl = deriveFilterTtl(scope, expiresAt);
206
+ const state =
207
+ new Date(expiresAt).getTime() > Date.now()
208
+ ? FilterState.Active
209
+ : FilterState.Expired;
210
+
211
+ return { scope, expiresAt, ttl, state };
212
+ };
213
+
114
214
  const toFilterResponse = (item: FilterItem): FilterResponse => ({
115
215
  filterId: item.filterId,
116
216
  accountConfigId: item.accountConfigId,
@@ -260,17 +360,31 @@ export const FilterDetailOperations: Record<
260
360
  accountId: string;
261
361
  filterId: string;
262
362
  };
263
- const body = context.request.requestBody as Partial<UpdateFilterInput>;
363
+ const body = context.request.requestBody as Record<string, unknown>;
364
+ rejectAnchorMutation(body);
264
365
 
265
366
  const client = await getClient();
266
367
  const account = await client.account.get(accountId);
267
368
  assertAccountOwnership(account, accountConfigId, "act");
268
369
 
269
370
  const { filter } = client;
371
+ const patch = pickFilterUpdate(body as Partial<UpdateFilterInput>);
372
+ const touchesScopeOrExpiry =
373
+ Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
374
+ const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
375
+ ? {
376
+ ...patch,
377
+ ...resolveFilterScopeExpiry(
378
+ await filter.get(accountConfigId, filterId),
379
+ patch,
380
+ ),
381
+ }
382
+ : patch;
383
+
270
384
  const updated = await filter.update(
271
385
  accountConfigId,
272
386
  filterId,
273
- pickFilterUpdate(body),
387
+ resolvedPatch,
274
388
  );
275
389
  return toFilterResponse(updated);
276
390
  },