@abyss-project/tools 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/api/index.d.ts +2 -0
  2. package/dist/api/index.js +2 -0
  3. package/dist/api/index.js.map +1 -1
  4. package/dist/api/probe-usage.api.d.ts +2 -0
  5. package/dist/api/probe-usage.api.js +24 -0
  6. package/dist/api/probe-usage.api.js.map +1 -0
  7. package/dist/api/probe-usage.application.api.d.ts +2 -0
  8. package/dist/api/probe-usage.application.api.js +12 -0
  9. package/dist/api/probe-usage.application.api.js.map +1 -0
  10. package/dist/api/probe-webhook.api.d.ts +3 -1
  11. package/dist/api/probe-webhook.api.js +8 -1
  12. package/dist/api/probe-webhook.api.js.map +1 -1
  13. package/dist/api/probe-webhook.application.api.d.ts +3 -1
  14. package/dist/api/probe-webhook.application.api.js +14 -1
  15. package/dist/api/probe-webhook.application.api.js.map +1 -1
  16. package/dist/index.d.ts +12 -0
  17. package/dist/index.js +12 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/types/enum/beam.enum.d.ts +8 -2
  20. package/dist/types/enum/beam.enum.js +9 -2
  21. package/dist/types/enum/beam.enum.js.map +1 -1
  22. package/dist/types/interface/api/requests/index.d.ts +1 -0
  23. package/dist/types/interface/api/requests/index.js +1 -0
  24. package/dist/types/interface/api/requests/index.js.map +1 -1
  25. package/dist/types/interface/api/requests/probe-usage.request.d.ts +3 -0
  26. package/dist/types/interface/api/requests/probe-usage.request.js +3 -0
  27. package/dist/types/interface/api/requests/probe-usage.request.js.map +1 -0
  28. package/dist/types/interface/api/requests/probe-webhook.request.d.ts +8 -0
  29. package/dist/types/interface/api/responses/beam.response.d.ts +2 -1
  30. package/dist/types/interface/api/responses/index.d.ts +1 -0
  31. package/dist/types/interface/api/responses/index.js +1 -0
  32. package/dist/types/interface/api/responses/index.js.map +1 -1
  33. package/dist/types/interface/api/responses/probe-scan.response.d.ts +1 -0
  34. package/dist/types/interface/api/responses/probe-usage.response.d.ts +7 -0
  35. package/dist/types/interface/api/responses/probe-usage.response.js +3 -0
  36. package/dist/types/interface/api/responses/probe-usage.response.js.map +1 -0
  37. package/dist/types/interface/api/responses/probe-webhook.response.d.ts +4 -1
  38. package/dist/types/interface/models/probe-scan.dto.d.ts +8 -0
  39. package/dist/types/interface/models/probe-webhook-delivery.dto.d.ts +12 -0
  40. package/dist/types/interface/models/probe-webhook.dto.d.ts +2 -3
  41. package/dist/types/tests/probe.dto.test.js +40 -1
  42. package/dist/types/tests/probe.dto.test.js.map +1 -1
  43. package/dist/utils/index.d.ts +1 -0
  44. package/dist/utils/index.js +1 -0
  45. package/dist/utils/index.js.map +1 -1
  46. package/dist/utils/probe-scan-cost.utils.d.ts +5 -0
  47. package/dist/utils/probe-scan-cost.utils.js +21 -0
  48. package/dist/utils/probe-scan-cost.utils.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/api/index.ts +2 -0
  51. package/src/api/probe-usage.api.ts +47 -0
  52. package/src/api/probe-usage.application.api.ts +25 -0
  53. package/src/api/probe-webhook.api.ts +43 -5
  54. package/src/api/probe-webhook.application.api.ts +58 -4
  55. package/src/index.ts +28 -0
  56. package/src/types/enum/beam.enum.ts +17 -1
  57. package/src/types/interface/api/requests/index.ts +1 -0
  58. package/src/types/interface/api/requests/probe-usage.request.ts +7 -0
  59. package/src/types/interface/api/requests/probe-webhook.request.ts +23 -2
  60. package/src/types/interface/api/responses/beam.response.ts +10 -1
  61. package/src/types/interface/api/responses/index.ts +1 -0
  62. package/src/types/interface/api/responses/probe-scan.response.ts +7 -0
  63. package/src/types/interface/api/responses/probe-usage.response.ts +25 -0
  64. package/src/types/interface/api/responses/probe-webhook.response.ts +10 -3
  65. package/src/types/interface/models/probe-scan.dto.ts +27 -8
  66. package/src/types/interface/models/probe-webhook-delivery.dto.ts +47 -0
  67. package/src/types/interface/models/probe-webhook.dto.ts +25 -11
  68. package/src/types/tests/probe.dto.test.ts +73 -11
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/probe-scan-cost.utils.ts +101 -0
@@ -1,13 +1,14 @@
1
1
  import { IResponse } from '../type-message/response';
2
- import { IProbeWebhook, IProbeWebhookCreated } from '../../models';
2
+ import { BasePaginate } from '../type-message/base-paginate';
3
+ import { IProbeWebhook, IProbeWebhookCreated, IProbeWebhookDelivery } from '../../models';
3
4
 
4
5
  // Mirrors apps/api/src/api/controllers/probe-webhook.controller.ts's local
5
6
  // response shapes EXACTLY.
6
7
 
7
- /** `POST /webhook` — HTTP 201. The only response that ever carries `secret`. */
8
+ /** `POST /webhook` — HTTP 201. `secret` is re-displayable (WS5-7) — also returned by GET/PATCH, not just here. */
8
9
  export type ICreateProbeWebhookResponse = IResponse<IProbeWebhookCreated>;
9
10
 
10
- /** `GET /webhook` — HTTP 200. A flat scope-filtered list, NOT paginated. */
11
+ /** `GET /webhook` — HTTP 200. A flat scope-filtered list, NOT paginated. `secret` included per row (WS5-7). */
11
12
  export interface IListProbeWebhookData {
12
13
  webhooks: IProbeWebhook[];
13
14
  }
@@ -15,3 +16,9 @@ export type IListProbeWebhookResponse = IResponse<IListProbeWebhookData>;
15
16
 
16
17
  /** `DELETE /webhook/:webhookId` — HTTP 200. */
17
18
  export type IDeleteProbeWebhookResponse = IResponse<{ ok: true }>;
19
+
20
+ /** `PATCH /webhook/:webhookId` — HTTP 200 (WS5-5). `secret` included, same as every other webhook read. */
21
+ export type IUpdateProbeWebhookResponse = IResponse<IProbeWebhook>;
22
+
23
+ /** `GET /webhook/:webhookId/deliveries` — HTTP 200, paginated (WS5-6). */
24
+ export type IListProbeWebhookDeliveriesResponse = IResponse<BasePaginate<IProbeWebhookDelivery>>;
@@ -8,9 +8,13 @@ import { ProbeScanMode, ProbeScanStatus, ProbeScanVerdict } from '../../enum';
8
8
 
9
9
  /**
10
10
  * `GET /scan/:scanId` shape (poll the queued -> scanning -> done/error
11
- * progression). Deliberately minimal: no fileName/fileSize/mimeType/mode/
12
- * timestamps, no internal ownership columns (userId/organizationId/
13
- * callbackUrl) — a caller polling by id already knows what it submitted.
11
+ * progression). WS5-4 (AbyssProbe UX rework) widened this to full metadata
12
+ * parity with {@link IProbeScanHistoryItem} (fileName/fileSize/mimeType/
13
+ * mode/startedAt/finishedAt/costUnits) — the scanner UI's "Détails" section
14
+ * needs the same info whether it got there via the list or via a direct
15
+ * poll — still never echoing internal ownership columns (userId,
16
+ * organizationId, callbackUrl) — a caller polling by id already knows what
17
+ * it submitted.
14
18
  */
15
19
  export interface IProbeScanDetail {
16
20
  scanId: string;
@@ -21,14 +25,27 @@ export interface IProbeScanDetail {
21
25
  viruses: string[] | null;
22
26
  /** Free-form caller reference echoed back verbatim (see IProbeScanHistoryItem#context). */
23
27
  context: Record<string, unknown> | null;
28
+ fileName: string;
29
+ fileSize: number;
30
+ mimeType: string;
31
+ mode: ProbeScanMode;
32
+ /** Set the moment the engine call starts (SYNC: row-creation time; ASYNC: when a worker picks the job off the queue). */
33
+ startedAt: Date;
34
+ /** Set once the engine call returns. null while status is not DONE. */
35
+ finishedAt: Date | null;
36
+ /**
37
+ * `ACCOUNT_AV_SCAN_UNITS` this scan consumed (see `estimateProbeScanCost`
38
+ * — this package's own shared pure cost model). null until `status` is
39
+ * DONE; stays null forever for a scan that errors out (no verdict was ever
40
+ * reached, so nothing was ever billed).
41
+ */
42
+ costUnits: number | null;
24
43
  }
25
44
 
26
45
  /**
27
- * One row of `GET /scan`'s paginated history/search list. Richer than
28
- * {@link IProbeScanDetail} (adds fileName/fileSize/mimeType/mode/timestamps)
29
- * but still never echoes internal ownership columns (userId, organizationId,
30
- * callbackUrl) — a caller already knows their own scope from the request
31
- * they made.
46
+ * One row of `GET /scan`'s paginated history/search list. Never echoes
47
+ * internal ownership columns (userId, organizationId, callbackUrl) — a
48
+ * caller already knows their own scope from the request they made.
32
49
  */
33
50
  export interface IProbeScanHistoryItem {
34
51
  scanId: string;
@@ -44,5 +61,7 @@ export interface IProbeScanHistoryItem {
44
61
  startedAt: Date;
45
62
  /** Set once the engine call returns. null while status is not DONE. */
46
63
  finishedAt: Date | null;
64
+ /** See {@link IProbeScanDetail#costUnits}. */
65
+ costUnits: number | null;
47
66
  createdAt: Date;
48
67
  }
@@ -23,4 +23,51 @@ export interface IProbeWebhookDeliveryPayload {
23
23
  verdict: ProbeScanVerdict;
24
24
  /** [] when clean. */
25
25
  viruses: string[];
26
+ /**
27
+ * `ACCOUNT_AV_SCAN_UNITS` this scan consumed (WS5-1). Delivery only ever
28
+ * happens on a DONE scan, where the cost is always already computed and
29
+ * billed — see {@link IProbeScanHistoryItem#costUnits}.
30
+ */
31
+ costUnits: number | null;
32
+ }
33
+
34
+ // ─── Delivery HISTORY (WS5-6) ────────────────
35
+ //
36
+ // The interface above is the PAYLOAD a webhook receiver gets POSTed. This
37
+ // section is a DIFFERENT concept: the persisted AUDIT ROW AbyssProbe keeps
38
+ // of its OWN delivery attempts (one row per attempt — mirrors
39
+ // apps/api/src/database/models/probe-webhook-delivery.model.ts EXACTLY,
40
+ // itself modelled after this package's own extraction-webhook
41
+ // delivery-history shape, trimmed to AbyssProbe's simpler
42
+ // one-attempt-per-row reality — see that model's header comment). Read via
43
+ // `GET /webhook/:webhookId/deliveries` (`sdk.probe.webhook.listDeliveries`).
44
+
45
+ /**
46
+ * One persisted delivery-attempt row, as returned (paginated) by
47
+ * `GET /webhook/:webhookId/deliveries`.
48
+ */
49
+ export interface IProbeWebhookDelivery {
50
+ id: string;
51
+ /**
52
+ * The registered webhook this delivery targeted. null for a delivery to a
53
+ * scan's own ad-hoc per-request `callbackUrl` that matched no registered
54
+ * webhook (see the server model's own doc comment) — such a row is never
55
+ * reachable via this webhook-scoped endpoint in practice (there is no
56
+ * webhookId to query it under), but the field stays nullable to mirror the
57
+ * server column's real type.
58
+ */
59
+ webhookId: string | null;
60
+ /** The scan whose verdict this delivery carried. */
61
+ scanId: string;
62
+ /** The exact URL this attempt POSTed to. */
63
+ url: string;
64
+ /** HTTP status code received. null when the request never got a response (network error / timeout / abort). */
65
+ statusCode: number | null;
66
+ /** true only for a 2xx response. */
67
+ success: boolean;
68
+ /** When this attempt was made. */
69
+ attemptedAt: Date;
70
+ /** Failure reason (HTTP non-2xx status, or the caught network/timeout error's message). null on success. */
71
+ error: string | null;
72
+ createdAt: Date;
26
73
  }
@@ -1,25 +1,39 @@
1
1
  // Mirrors apps/api/src/api/controllers/probe-webhook.controller.ts's local
2
2
  // WebhookResponseData / CreateWebhookResponseData. Public shape of a
3
- // registered `probe_webhooks` row — never carries `secret` except right
4
- // after creation (see IProbeWebhookCreated below); never echoes the
5
- // internal ownership columns (userId/organizationId/applicationId) either,
6
- // same discipline as the scan models.
3
+ // registered `probe_webhooks` row — never echoes the internal ownership
4
+ // columns (userId/organizationId/applicationId), same discipline as the
5
+ // scan models.
6
+ //
7
+ // WS5-7 (AbyssProbe UX rework): `secret` is ALWAYS re-displayable now —
8
+ // returned on every read (GET /webhook list, and PATCH /webhook/:id), not
9
+ // just once on create. Mirrors how AbyssExtract's own webhook model
10
+ // (`IExtractionWebhookDelivery`'s sibling `ExtractionWebhook` — see
11
+ // apps/api/src/database/models/extraction-webhook.model.ts /
12
+ // extraction-webhook.controller.ts) has always exposed its
13
+ // `webhookSecret` column on every response, never a once-only reveal. This
14
+ // lets a consumer (e.g. abyss-storage's own webhook registration) read its
15
+ // secret back at any time instead of relying on a log-once bootstrap.
7
16
 
8
17
  export interface IProbeWebhook {
9
18
  id: string;
10
19
  /** HTTPS endpoint the verdict is POSTed to. */
11
20
  callbackUrl: string;
21
+ /**
22
+ * Server-generated HMAC-SHA256 signing secret. Re-displayable: returned on
23
+ * every read (list/create/update), not once-only — see this interface's
24
+ * own header note (WS5-7).
25
+ */
26
+ secret: string;
12
27
  /** Disabled webhooks are skipped by dispatch (soft-off, not deleted). */
13
28
  active: boolean;
14
29
  createdAt: Date;
15
30
  }
16
31
 
17
32
  /**
18
- * Create-only response shape: the ONE time `secret` is ever returned by the
19
- * API ("Secret generated server-side, returned once on create"). Store it
20
- * immediately — there is no reset-secret / re-reveal endpoint (see this
21
- * package's header note on the deliberately small CRUD surface).
33
+ * @deprecated Structurally identical to {@link IProbeWebhook} now that
34
+ * `secret` is always re-displayable (WS5-7) — kept as a source-compatible
35
+ * alias (no removal, no consumer breakage) for existing imports that named
36
+ * this type explicitly for the old "only right after creation" contract.
37
+ * Prefer {@link IProbeWebhook} directly in new code.
22
38
  */
23
- export interface IProbeWebhookCreated extends IProbeWebhook {
24
- secret: string;
25
- }
39
+ export type IProbeWebhookCreated = IProbeWebhook;
@@ -1,9 +1,10 @@
1
1
  // Type-level compile test for AbyssProbe's DTO/response shapes — asserts
2
2
  // they match the server contract (probe-scan.controller.ts /
3
- // probe-webhook.controller.ts / probe-webhook-delivery.service.ts) at
4
- // compile time: any incompatible rename/removal on either side fails
5
- // `npm run build` here. No jest runner in this SDK package; compile guard
6
- // only (mirrors ../api/extraction-plan-preset.api.test.ts). Run: npx tsc --noEmit.
3
+ // probe-webhook.controller.ts / probe-usage.controller.ts /
4
+ // probe-webhook-delivery.service.ts) at compile time: any incompatible
5
+ // rename/removal on either side fails `npm run build` here. No jest runner
6
+ // in this SDK package; compile guard only (mirrors
7
+ // ../api/extraction-plan-preset.api.test.ts). Run: npx tsc --noEmit.
7
8
 
8
9
  import {
9
10
  ProbeScanVerdict,
@@ -13,10 +14,18 @@ import {
13
14
  IProbeScanHistoryItem,
14
15
  IProbeWebhook,
15
16
  IProbeWebhookCreated,
17
+ IProbeWebhookDelivery,
16
18
  IProbeWebhookDeliveryPayload,
17
19
  IScanSyncData,
18
20
  IScanAsyncData,
21
+ IUpdateProbeWebhookBody,
22
+ IProbeUsageData,
19
23
  } from '..';
24
+ // estimateProbeScanCost lives in src/utils/ (a SIBLING of src/types/, not
25
+ // re-exported by the types barrel this file otherwise imports from) — see
26
+ // probe-scan-cost.utils.ts's own header comment for why it is a plain
27
+ // top-level export rather than a `sdk.probe.*` method.
28
+ import { estimateProbeScanCost } from '../../utils';
20
29
 
21
30
  // Enum exhaustiveness: fails to compile if a member is added/removed/renamed
22
31
  // here without updating this file — a cheap drift guard for values
@@ -31,17 +40,24 @@ const _statuses: Record<ProbeScanStatus, true> = {
31
40
  error: true,
32
41
  };
33
42
 
34
- // GET /scan/:scanId
43
+ // GET /scan/:scanId — WS5-4: full metadata parity with IProbeScanHistoryItem
44
+ // (fileName/fileSize/mimeType/mode/startedAt/finishedAt/costUnits).
35
45
  const _scanDetail: IProbeScanDetail = {
36
46
  scanId: 's1',
37
47
  status: ProbeScanStatus.DONE,
38
48
  verdict: ProbeScanVerdict.CLEAN,
39
49
  viruses: [],
40
50
  context: null,
51
+ fileName: 'invoice.pdf',
52
+ fileSize: 1024,
53
+ mimeType: 'application/pdf',
54
+ mode: ProbeScanMode.SYNC,
55
+ startedAt: new Date(),
56
+ finishedAt: new Date(),
57
+ costUnits: 3,
41
58
  };
42
59
 
43
- // GET /scan history row — richer than IProbeScanDetail (fileName/fileSize/
44
- // mimeType/mode/timestamps), still never the internal ownership columns.
60
+ // GET /scan history row — WS5-1: costUnits added.
45
61
  const _historyItem: IProbeScanHistoryItem = {
46
62
  scanId: 's1',
47
63
  fileName: 'invoice.pdf',
@@ -54,35 +70,75 @@ const _historyItem: IProbeScanHistoryItem = {
54
70
  context: { applicationFileId: 'af1' },
55
71
  startedAt: new Date(),
56
72
  finishedAt: new Date(),
73
+ costUnits: 1,
57
74
  createdAt: new Date(),
58
75
  };
59
76
 
60
- // POST /scan — sync (200) / async (202) data shapes.
77
+ // POST /scan — sync (200) / async (202) data shapes. WS5-1: costUnits added to the sync shape (always known/billed by the time a 200 is returned).
61
78
  const _scanSyncData: IScanSyncData = {
62
79
  scanId: 's1',
63
80
  verdict: ProbeScanVerdict.CLEAN,
64
81
  viruses: [],
82
+ costUnits: 3,
65
83
  };
66
84
  const _scanAsyncData: IScanAsyncData = { scanId: 's1', status: ProbeScanStatus.QUEUED };
67
85
 
68
- // Webhook subscription — list/get shape vs the create-only `secret` shape.
86
+ // Webhook subscription — WS5-7: secret is now ALWAYS present (list/create/
87
+ // update), IProbeWebhookCreated is a source-compatible alias of IProbeWebhook.
69
88
  const _webhook: IProbeWebhook = {
70
89
  id: 'w1',
71
90
  callbackUrl: 'https://example.com/hook',
91
+ secret: 'shh',
72
92
  active: true,
73
93
  createdAt: new Date(),
74
94
  };
75
- const _webhookCreated: IProbeWebhookCreated = { ..._webhook, secret: 'shh' };
95
+ const _webhookCreated: IProbeWebhookCreated = _webhook;
96
+
97
+ // PATCH /webhook/:webhookId body (WS5-5) — every field optional (at least one required server-side).
98
+ const _updateBody: IUpdateProbeWebhookBody = { active: false };
99
+ const _updateBody2: IUpdateProbeWebhookBody = { callbackUrl: 'https://example.com/hook2' };
76
100
 
77
101
  // Webhook delivery payload — the exact body signed by x-probe-signature
78
- // (see verifyProbeWebhookSignature).
102
+ // (see verifyProbeWebhookSignature). WS5-1: costUnits added.
79
103
  const _deliveryPayload: IProbeWebhookDeliveryPayload = {
80
104
  scanId: 's1',
81
105
  context: null,
82
106
  verdict: ProbeScanVerdict.INFECTED,
83
107
  viruses: ['Eicar-Test-Signature'],
108
+ costUnits: 5,
84
109
  };
85
110
 
111
+ // GET /webhook/:webhookId/deliveries row (WS5-6) — a persisted delivery-attempt.
112
+ const _deliveryRecord: IProbeWebhookDelivery = {
113
+ id: 'd1',
114
+ webhookId: 'w1',
115
+ scanId: 's1',
116
+ url: 'https://example.com/hook',
117
+ statusCode: 200,
118
+ success: true,
119
+ attemptedAt: new Date(),
120
+ error: null,
121
+ createdAt: new Date(),
122
+ };
123
+ // webhookId is nullable (ad-hoc per-request callbackUrl with no matching registration).
124
+ const _deliveryRecordAdHoc: IProbeWebhookDelivery = { ..._deliveryRecord, webhookId: null };
125
+
126
+ // GET /usage data shape (WS5-3).
127
+ const _usage: IProbeUsageData = { used: 10, limit: 1000, remaining: 990 };
128
+ // -1 is the established "unlimited" sentinel (see estimateProbeScanCost's
129
+ // server-side counterpart, utils/billing-emit.util.ts#UNLIMITED_EXPIRY_DAYS).
130
+ const _usageUnlimited: IProbeUsageData = { used: 10, limit: -1, remaining: -1 };
131
+
132
+ // estimateProbeScanCost (WS5-2) — pure shared cost model, ASYNC must cost
133
+ // strictly less than SYNC for the same size (the scanner UI's "background
134
+ // analysis saves tokens" tooltip depends on this). A one-byte compile-time
135
+ // smoke check; the full behavioural suite lives in probe-scan-cost.utils.test.ts.
136
+ const _asyncCost: number = estimateProbeScanCost(5 * 1024 * 1024, ProbeScanMode.ASYNC);
137
+ const _syncCost: number = estimateProbeScanCost(5 * 1024 * 1024, ProbeScanMode.SYNC);
138
+ if (_asyncCost >= _syncCost) {
139
+ throw new Error('estimateProbeScanCost: ASYNC must cost strictly less than SYNC');
140
+ }
141
+
86
142
  void _verdicts;
87
143
  void _modes;
88
144
  void _statuses;
@@ -92,6 +148,12 @@ void _scanSyncData;
92
148
  void _scanAsyncData;
93
149
  void _webhook;
94
150
  void _webhookCreated;
151
+ void _updateBody;
152
+ void _updateBody2;
95
153
  void _deliveryPayload;
154
+ void _deliveryRecord;
155
+ void _deliveryRecordAdHoc;
156
+ void _usage;
157
+ void _usageUnlimited;
96
158
 
97
159
  export {};
@@ -5,3 +5,4 @@ export * from './icons.utils';
5
5
  export * from './enum.utils';
6
6
  export * from './extraction-webhook.utils';
7
7
  export * from './probe-webhook-signature.utils';
8
+ export * from './probe-scan-cost.utils';
@@ -0,0 +1,101 @@
1
+ // ──────────────────────────────────────────────
2
+ // AbyssProbe scan cost model — pure, shared (client + server, no endpoint).
3
+ //
4
+ // Ported from abyss-storage's OWN weighted AV-scan cost model
5
+ // (`apps/api/src/services/av-scan-usage.ts#computeAvScanCost`, introduced in
6
+ // storage commit 317e4a0 "wire the weighted AV-scan quota", since made dead
7
+ // code there by the AV v3 Phase 3b-2 hand-off — "storage never bills —
8
+ // AbyssProbe does" — but the FORMULA itself is exactly what AbyssProbe's own
9
+ // weighted billing needs, so it is ported here verbatim rather than
10
+ // reinvented):
11
+ //
12
+ // cost = round( (BASE + K_MB * ceil(sizeOctet / 1 MiB)) * MODE_WEIGHT[mode] )
13
+ //
14
+ // ASYNC costs LESS than SYNC (MODE_WEIGHT.async=1 < MODE_WEIGHT.sync=3) — a
15
+ // synchronous scan holds the caller's request (and an engine worker) until
16
+ // the verdict and cannot be batched/load-balanced, so it costs more than an
17
+ // asynchronous (queued) scan of the SAME bytes. This is the numeric backing
18
+ // for the scanner UI's "l'analyse en arrière-plan économise des tokens"
19
+ // tooltip (WS1).
20
+ //
21
+ // PURE + DETERMINISTIC + no I/O: this is the SINGLE source of truth for
22
+ // AbyssProbe's scan cost, used on BOTH sides of the wire —
23
+ // - server (apps/api/src/services/probe-scan.service.ts): the REAL cost
24
+ // billed via recordProbeScanUsage and persisted on probe_scans.costUnits
25
+ // once a scan reaches a verdict (DONE).
26
+ // - client (the AbyssProbe UI, or any external caller): a cost PREVIEW
27
+ // before scanning — call this with the picked file's size and the
28
+ // intended mode to show the estimated token cost, matching exactly what
29
+ // the server will bill (no separate "estimate" formula to drift out of
30
+ // sync with the real one — same function, same constants, both sides).
31
+ //
32
+ // Deliberately NOT exposed as an HTTP endpoint (no `POST /scan/estimate`):
33
+ // the whole point of a pure function is that the client can compute it
34
+ // locally, instantly, with zero round-trip — see this package's root README
35
+ // note on `sdk.probe.*`'s HTTP-call-only convention; a helper like this one
36
+ // (and `verifyProbeWebhookSignature` before it) is instead a plain top-level
37
+ // export, not a `sdk.probe.*` method.
38
+ // ──────────────────────────────────────────────
39
+
40
+ import { ProbeScanMode } from '../types';
41
+
42
+ /** Flat floor every scanned file pays (the "one scan" cost). Tunable. */
43
+ export const PROBE_SCAN_COST_BASE = 1;
44
+
45
+ /** Additional units per (ceil'd) mebibyte of scanned bytes. Tunable. */
46
+ export const PROBE_SCAN_COST_K_MB = 1;
47
+
48
+ /**
49
+ * Per-mode cost multiplier. A synchronous scan blocks the caller's request
50
+ * (and an engine worker) until the verdict and cannot be queued/batched, so
51
+ * it costs strictly MORE than an asynchronous scan of the same bytes —
52
+ * ASYNC < SYNC is a load-bearing invariant relied on by the scanner UI's
53
+ * "background analysis saves tokens" messaging (WS1). Tunable, but this
54
+ * ordering must never be inverted.
55
+ */
56
+ export const PROBE_SCAN_COST_MODE_WEIGHT: Record<ProbeScanMode, number> = {
57
+ [ProbeScanMode.ASYNC]: 1,
58
+ [ProbeScanMode.SYNC]: 3,
59
+ };
60
+
61
+ /** One mebibyte, the unit the per-MB term is quantised to. */
62
+ const ONE_MB = 1024 * 1024;
63
+
64
+ /**
65
+ * Weighted cost of one AbyssProbe scan, in `ACCOUNT_AV_SCAN_UNITS` ("tokens"
66
+ * in the UI). Pure + deterministic — same inputs always produce the same
67
+ * output, no I/O, safe to call from a browser or a server.
68
+ *
69
+ * Returns `0` for a non-finite / non-positive `sizeOctet` (nothing to scan,
70
+ * nothing to bill) rather than throwing — a defensive, never-crash contract
71
+ * appropriate for a client-side cost preview that might run ahead of full
72
+ * input validation (e.g. while a file is still being selected).
73
+ *
74
+ * `mode` defaults to the (cheaper) ASYNC weight when given an unrecognised
75
+ * value, mirroring the ported formula's own `MODE_WEIGHT[mode] ?? MODE_WEIGHT.async`
76
+ * fallback — never silently the MORE expensive weight.
77
+ *
78
+ * `mode`'s type is widened to `| 'sync' | 'async'` alongside the enum
79
+ * itself, NOT just `ProbeScanMode`: apps/api's own server-side
80
+ * `database/probe-scan.enum.ts#ProbeScanMode` is a SEPARATE TypeScript enum
81
+ * declaration (kept server-local — see that file's header comment on why —
82
+ * with the identical 'sync'/'async' runtime values, so the wire format never
83
+ * drifts). TypeScript string enums are nominal: a same-valued member of that
84
+ * OTHER enum is not directly assignable to THIS package's `ProbeScanMode`
85
+ * parameter type, but IS assignable to a plain string-literal type matching
86
+ * its value — so the server can call this exact function (`scan.mode`,
87
+ * `estimateProbeScanCost`'s whole reason for living being ONE shared formula
88
+ * on both sides of the wire, see this file's header comment) without a cast.
89
+ */
90
+ export const estimateProbeScanCost = (
91
+ sizeOctet: number,
92
+ mode: ProbeScanMode | 'sync' | 'async',
93
+ ): number => {
94
+ if (!Number.isFinite(sizeOctet) || sizeOctet <= 0) {
95
+ return 0;
96
+ }
97
+ const megabytes = Math.ceil(sizeOctet / ONE_MB);
98
+ const weight =
99
+ PROBE_SCAN_COST_MODE_WEIGHT[mode] ?? PROBE_SCAN_COST_MODE_WEIGHT[ProbeScanMode.ASYNC];
100
+ return Math.round((PROBE_SCAN_COST_BASE + PROBE_SCAN_COST_K_MB * megabytes) * weight);
101
+ };