@echovisionlab/geul-telemetry 0.1.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 (58) hide show
  1. package/LICENSE.md +6 -0
  2. package/README.md +38 -0
  3. package/fixtures/audit-records.json +316 -0
  4. package/fixtures/collaboration-checkpoint-entity-types.json +15 -0
  5. package/fixtures/domain-audit-actions.json +132 -0
  6. package/fixtures/domain-audit-wire-parity.json +1751 -0
  7. package/fixtures/http-request-results.json +21 -0
  8. package/fixtures/job-catalog.json +23 -0
  9. package/fixtures/locale-content-audit.json +126 -0
  10. package/fixtures/log-redaction.json +72 -0
  11. package/fixtures/request-record.json +14 -0
  12. package/fixtures/security-access-records.json +61 -0
  13. package/fixtures/service-identities.json +11 -0
  14. package/fixtures/system-catalog.json +28 -0
  15. package/fixtures/translation-job-terminal-catalog.json +45 -0
  16. package/fixtures/translation-settings-fields.json +1 -0
  17. package/package.json +61 -0
  18. package/policy/log-redaction.json +113 -0
  19. package/src/actor.ts +81 -0
  20. package/src/audit/account.ts +206 -0
  21. package/src/audit/actor-policy.ts +63 -0
  22. package/src/audit/artist-label.ts +312 -0
  23. package/src/audit/attributes.ts +107 -0
  24. package/src/audit/builder.ts +25 -0
  25. package/src/audit/catalog.ts +116 -0
  26. package/src/audit/content.ts +723 -0
  27. package/src/audit/email-authoring.ts +220 -0
  28. package/src/audit/file.ts +168 -0
  29. package/src/audit/form.ts +229 -0
  30. package/src/audit/integrations.ts +207 -0
  31. package/src/audit/locale-code.ts +6 -0
  32. package/src/audit/locale-content.ts +358 -0
  33. package/src/audit/member-account.ts +313 -0
  34. package/src/audit/member.ts +171 -0
  35. package/src/audit/program.ts +339 -0
  36. package/src/audit/reference-data.ts +230 -0
  37. package/src/audit/reference-entities.ts +332 -0
  38. package/src/audit/relation-download-policy.ts +292 -0
  39. package/src/audit/release-campaign.ts +422 -0
  40. package/src/audit/series-type.ts +298 -0
  41. package/src/audit/settings.ts +306 -0
  42. package/src/audit/source-locale.ts +337 -0
  43. package/src/audit/types.ts +9 -0
  44. package/src/audit.ts +21 -0
  45. package/src/authentication.ts +183 -0
  46. package/src/error-metadata.ts +11 -0
  47. package/src/index.ts +11 -0
  48. package/src/job.ts +56 -0
  49. package/src/log-attribute-policy.ts +21 -0
  50. package/src/otel.ts +64 -0
  51. package/src/records.ts +1244 -0
  52. package/src/redaction.ts +35 -0
  53. package/src/request-context.ts +73 -0
  54. package/src/request-id.ts +13 -0
  55. package/src/request-result.ts +73 -0
  56. package/src/request.ts +60 -0
  57. package/src/system.ts +355 -0
  58. package/src/trace.ts +13 -0
@@ -0,0 +1,206 @@
1
+ import type {
2
+ AccountSessionScope,
3
+ AuditCollectionOperation,
4
+ AuditRecord,
5
+ AuditState,
6
+ } from "../records.ts";
7
+ import { canonicalAuditValues } from "./attributes.ts";
8
+ import { buildAuditRecord } from "./builder.ts";
9
+ import type { AuditMetadata } from "./types.ts";
10
+
11
+ function updated(
12
+ metadata: AuditMetadata,
13
+ memberId: string,
14
+ attributes: Parameters<typeof buildAuditRecord>[2],
15
+ ): AuditRecord {
16
+ return buildAuditRecord(
17
+ metadata,
18
+ {
19
+ action: "account.updated",
20
+ target_type: "account",
21
+ target_id: memberId,
22
+ },
23
+ attributes,
24
+ );
25
+ }
26
+ export function buildAccountCanonicalEmailUpdatedAuditRecord(
27
+ metadata: AuditMetadata,
28
+ memberId: string,
29
+ previousEmail: string,
30
+ newEmail: string,
31
+ ): AuditRecord {
32
+ return updated(metadata, memberId, {
33
+ changed_fields: ["canonical_email"],
34
+ previous_email: previousEmail,
35
+ new_email: newEmail,
36
+ });
37
+ }
38
+ function login(
39
+ metadata: AuditMetadata,
40
+ memberId: string,
41
+ operation: AuditCollectionOperation,
42
+ email: string,
43
+ ): AuditRecord {
44
+ return updated(metadata, memberId, {
45
+ changed_fields: ["login_emails"],
46
+ collection_operation: operation,
47
+ email,
48
+ });
49
+ }
50
+ export function buildAccountEmailLoginAddedAuditRecord(
51
+ metadata: AuditMetadata,
52
+ memberId: string,
53
+ email: string,
54
+ ): AuditRecord {
55
+ return login(metadata, memberId, "added", email);
56
+ }
57
+ export function buildAccountEmailLoginRemovedAuditRecord(
58
+ metadata: AuditMetadata,
59
+ memberId: string,
60
+ email: string,
61
+ ): AuditRecord {
62
+ return login(metadata, memberId, "removed", email);
63
+ }
64
+ function social(
65
+ metadata: AuditMetadata,
66
+ memberId: string,
67
+ operation: AuditCollectionOperation,
68
+ provider: string,
69
+ providerSubject: string,
70
+ ): AuditRecord {
71
+ return updated(metadata, memberId, {
72
+ changed_fields: ["social_logins"],
73
+ collection_operation: operation,
74
+ provider,
75
+ provider_subject: providerSubject,
76
+ });
77
+ }
78
+ export function buildAccountSocialLoginAddedAuditRecord(
79
+ metadata: AuditMetadata,
80
+ memberId: string,
81
+ provider: string,
82
+ providerSubject: string,
83
+ ): AuditRecord {
84
+ return social(metadata, memberId, "added", provider, providerSubject);
85
+ }
86
+ export function buildAccountSocialLoginRemovedAuditRecord(
87
+ metadata: AuditMetadata,
88
+ memberId: string,
89
+ provider: string,
90
+ providerSubject: string,
91
+ ): AuditRecord {
92
+ return social(metadata, memberId, "removed", provider, providerSubject);
93
+ }
94
+ function passkeys(
95
+ metadata: AuditMetadata,
96
+ memberId: string,
97
+ operation: AuditCollectionOperation,
98
+ passkeyIds: readonly string[],
99
+ ): AuditRecord {
100
+ return updated(metadata, memberId, {
101
+ changed_fields: ["passkeys"],
102
+ collection_operation: operation,
103
+ passkey_ids: canonicalAuditValues(passkeyIds),
104
+ });
105
+ }
106
+ export function buildAccountPasskeyAddedAuditRecord(
107
+ metadata: AuditMetadata,
108
+ memberId: string,
109
+ passkeyIds: readonly string[],
110
+ ): AuditRecord {
111
+ return passkeys(metadata, memberId, "added", passkeyIds);
112
+ }
113
+ export function buildAccountPasskeyRemovedAuditRecord(
114
+ metadata: AuditMetadata,
115
+ memberId: string,
116
+ passkeyIds: readonly string[],
117
+ ): AuditRecord {
118
+ return passkeys(metadata, memberId, "removed", passkeyIds);
119
+ }
120
+ export function buildAccountSessionRevokedAuditRecord(
121
+ metadata: AuditMetadata,
122
+ memberId: string,
123
+ sessionScope: AccountSessionScope,
124
+ sessionIds: readonly string[],
125
+ ): AuditRecord {
126
+ return updated(metadata, memberId, {
127
+ changed_fields: ["sessions"],
128
+ collection_operation: "removed",
129
+ session_scope: sessionScope,
130
+ session_ids: canonicalAuditValues(sessionIds),
131
+ });
132
+ }
133
+ export function buildAccountNewsletterSubscriptionUpdatedAuditRecord(
134
+ metadata: AuditMetadata,
135
+ memberId: string,
136
+ previousState: AuditState,
137
+ newState: AuditState,
138
+ ): AuditRecord {
139
+ return updated(metadata, memberId, {
140
+ changed_fields: ["newsletter_subscription"],
141
+ previous_state: previousState,
142
+ new_state: newState,
143
+ });
144
+ }
145
+ function deletionState(
146
+ metadata: AuditMetadata,
147
+ memberId: string,
148
+ previousState: AuditState,
149
+ newState: AuditState,
150
+ ): AuditRecord {
151
+ return updated(metadata, memberId, {
152
+ changed_fields: ["deletion_state"],
153
+ previous_state: previousState,
154
+ new_state: newState,
155
+ });
156
+ }
157
+ export function buildAccountDeletionRequestedAuditRecord(
158
+ metadata: AuditMetadata,
159
+ memberId: string,
160
+ previousState: AuditState,
161
+ ): AuditRecord {
162
+ return deletionState(
163
+ metadata,
164
+ memberId,
165
+ previousState,
166
+ "confirmation_pending",
167
+ );
168
+ }
169
+ export function buildAccountDeletionScheduledAuditRecord(
170
+ metadata: AuditMetadata,
171
+ memberId: string,
172
+ previousState: AuditState,
173
+ ): AuditRecord {
174
+ return deletionState(metadata, memberId, previousState, "scheduled");
175
+ }
176
+ export function buildAccountDeletionCancelledAuditRecord(
177
+ metadata: AuditMetadata,
178
+ memberId: string,
179
+ ): AuditRecord {
180
+ return deletionState(metadata, memberId, "scheduled", "cancelled");
181
+ }
182
+ export function buildAccountDeletionRecoveredAuditRecord(
183
+ metadata: AuditMetadata,
184
+ memberId: string,
185
+ ): AuditRecord {
186
+ return deletionState(
187
+ metadata,
188
+ memberId,
189
+ "recovery_confirmation_pending",
190
+ "recovered",
191
+ );
192
+ }
193
+ export function buildAccountDeletedAuditRecord(
194
+ metadata: AuditMetadata,
195
+ memberId: string,
196
+ ): AuditRecord {
197
+ return buildAuditRecord(
198
+ metadata,
199
+ {
200
+ action: "account.deleted",
201
+ target_type: "account",
202
+ target_id: memberId,
203
+ },
204
+ {},
205
+ );
206
+ }
@@ -0,0 +1,63 @@
1
+ import type { AuditRecord } from "../records.ts";
2
+
3
+ const SYSTEM_AUDIT_SERVICES: Readonly<Record<string, readonly string[]>> = {
4
+ "account.deleted": ["geul-backend"],
5
+ "file.deleted": ["geul-backend"],
6
+ "post.updated": ["geul-collab"],
7
+ "page.updated": ["geul-collab"],
8
+ "work.updated": ["geul-collab"],
9
+ "release.updated": ["geul-collab"],
10
+ "campaign.updated": ["geul-backend"],
11
+ "legal_policy.updated": ["geul-backend"],
12
+ "member.updated": ["geul-backend"],
13
+ };
14
+
15
+ const ANONYMOUS_AUDIT_ACTIONS = new Set(["form_submission.created"]);
16
+
17
+ export function auditActionAllowsAnonymous(action: string): boolean {
18
+ return ANONYMOUS_AUDIT_ACTIONS.has(action);
19
+ }
20
+
21
+ /** Explicit catalog policy for the few mutations a system actor may append. */
22
+ export function systemActorMayAppendAudit(record: AuditRecord): boolean {
23
+ if (
24
+ !SYSTEM_AUDIT_SERVICES[record.action]?.includes(
25
+ String(record.actor_service),
26
+ )
27
+ )
28
+ return false;
29
+ if (record.action === "release.updated")
30
+ return (
31
+ record.actor_service === "geul-collab" &&
32
+ record.changed_fields?.length === 1 &&
33
+ ["track_audio"].includes(record.changed_fields[0] ?? "")
34
+ );
35
+ if (record.action === "campaign.updated") {
36
+ return (
37
+ record.actor_service === "geul-backend" &&
38
+ record.changed_fields?.length === 1 &&
39
+ record.changed_fields[0] === "status" &&
40
+ (record.new_state === "sent" || record.new_state === "failed")
41
+ );
42
+ }
43
+ if (
44
+ record.action === "post.updated" ||
45
+ record.action === "page.updated" ||
46
+ record.action === "work.updated"
47
+ ) {
48
+ return (
49
+ record.actor_service === "geul-collab" &&
50
+ record.changed_fields?.length === 1 &&
51
+ record.changed_fields[0] === "version"
52
+ );
53
+ }
54
+ if (record.action !== "member.updated") return true;
55
+ return (
56
+ (record.changed_fields?.length === 1 &&
57
+ record.changed_fields[0] === "role") ||
58
+ (record.changed_fields?.length === 1 &&
59
+ record.changed_fields[0] === "status" &&
60
+ record.previous_state === "banned" &&
61
+ record.new_state === "active")
62
+ );
63
+ }
@@ -0,0 +1,312 @@
1
+ import type {
2
+ AuditAssetSlot,
3
+ AuditCollectionOperation,
4
+ AuditRecord,
5
+ AuditRelationship,
6
+ AuditState,
7
+ } from "../records.ts";
8
+ import { assertOnlyAuditAttributes } from "./attributes.ts";
9
+ import { buildAuditRecord } from "./builder.ts";
10
+ import {
11
+ AUDIT_ACTIONS,
12
+ AUDIT_CATALOG,
13
+ requireCatalogTarget,
14
+ } from "./catalog.ts";
15
+ import type { AuditMetadata } from "./types.ts";
16
+
17
+ type ArtistLabelTarget = "artist" | "label";
18
+ type ArtistLabelAction = Extract<
19
+ (typeof AUDIT_ACTIONS)[number],
20
+ | "artist.created"
21
+ | "artist.updated"
22
+ | "artist.deleted"
23
+ | "label.created"
24
+ | "label.updated"
25
+ | "label.deleted"
26
+ >;
27
+ type ArtistLabelLifecycleState = Extract<AuditState, "draft" | "published">;
28
+ type ShareLinkOperation = "created" | "deleted";
29
+
30
+ const targetTypes = new Set<ArtistLabelTarget>(["artist", "label"]);
31
+ const artistLabelActions = new Set<ArtistLabelAction>(
32
+ AUDIT_ACTIONS.filter((action) =>
33
+ targetTypes.has(AUDIT_CATALOG[action] as ArtistLabelTarget),
34
+ ) as ArtistLabelAction[],
35
+ );
36
+ function buildArtistLabelAuditRecord(
37
+ metadata: AuditMetadata,
38
+ action: ArtistLabelAction,
39
+ targetId: string,
40
+ attributes: Parameters<typeof buildAuditRecord>[2] = {},
41
+ ): AuditRecord {
42
+ return buildAuditRecord(
43
+ metadata,
44
+ { action, target_type: AUDIT_CATALOG[action], target_id: targetId },
45
+ attributes,
46
+ );
47
+ }
48
+ function requireNoExtra(record: AuditRecord, allowed: readonly string[]): void {
49
+ assertOnlyAuditAttributes(record, allowed);
50
+ }
51
+ function requireIdentifier(name: string, value: string | undefined): void {
52
+ if (!value || value.length > 255 || value.trim() !== value)
53
+ throw new TypeError(`${name} requires an identifier`);
54
+ }
55
+ function requireMemberActor(record: AuditRecord): void {
56
+ if (record.actor_kind !== "member")
57
+ throw new TypeError(
58
+ "artist and label mutation variants require member actor",
59
+ );
60
+ }
61
+ function requireLifecycle(record: AuditRecord): void {
62
+ if (
63
+ record.changed_fields?.length !== 1 ||
64
+ record.changed_fields[0] !== "status" ||
65
+ !(
66
+ (record.previous_state === "draft" && record.new_state === "published") ||
67
+ (record.previous_state === "published" && record.new_state === "draft")
68
+ )
69
+ )
70
+ throw new TypeError("lifecycle requires draft/published transition");
71
+ requireNoExtra(record, ["changed_fields", "previous_state", "new_state"]);
72
+ }
73
+ function requireParticipant(record: AuditRecord): void {
74
+ if (
75
+ record.changed_fields?.length !== 1 ||
76
+ record.changed_fields[0] !== "participants" ||
77
+ !["none", "owner", "manager"].includes(
78
+ record.previous_relationship ?? "",
79
+ ) ||
80
+ !["none", "owner", "manager"].includes(record.new_relationship ?? "") ||
81
+ record.previous_relationship === record.new_relationship
82
+ )
83
+ throw new TypeError(
84
+ "participant requires an allowed relationship transition",
85
+ );
86
+ requireIdentifier("subject_member_id", record.subject_member_id);
87
+ requireNoExtra(record, [
88
+ "changed_fields",
89
+ "subject_member_id",
90
+ "previous_relationship",
91
+ "new_relationship",
92
+ ]);
93
+ }
94
+ function requireShareLink(record: AuditRecord): void {
95
+ if (
96
+ record.changed_fields?.length !== 1 ||
97
+ record.changed_fields[0] !== "share_links" ||
98
+ (record.item_operation !== "created" && record.item_operation !== "deleted")
99
+ )
100
+ throw new TypeError(
101
+ "share link requires created or deleted item operation",
102
+ );
103
+ requireIdentifier("item_id", record.item_id);
104
+ requireNoExtra(record, ["changed_fields", "item_operation", "item_id"]);
105
+ }
106
+ function requireGallery(record: AuditRecord): void {
107
+ if (
108
+ record.changed_fields?.length !== 1 ||
109
+ record.changed_fields[0] !== "gallery" ||
110
+ record.file_ids === undefined
111
+ )
112
+ throw new TypeError("artist gallery requires file_ids");
113
+ const seen = new Set<string>();
114
+ for (const fileId of record.file_ids) {
115
+ requireIdentifier("file_ids", fileId);
116
+ if (seen.has(fileId))
117
+ throw new TypeError("artist gallery requires unique file_ids");
118
+ seen.add(fileId);
119
+ }
120
+ requireNoExtra(record, ["changed_fields", "file_ids"]);
121
+ }
122
+ function requireLogo(record: AuditRecord): void {
123
+ if (
124
+ record.changed_fields?.length !== 1 ||
125
+ record.changed_fields[0] !== "logo" ||
126
+ (record.asset_slot !== "light" && record.asset_slot !== "dark") ||
127
+ (record.collection_operation !== "added" &&
128
+ record.collection_operation !== "removed")
129
+ )
130
+ throw new TypeError("label logo requires slot and operation");
131
+ requireIdentifier("asset_id", record.asset_id);
132
+ requireNoExtra(record, [
133
+ "changed_fields",
134
+ "asset_slot",
135
+ "collection_operation",
136
+ "asset_id",
137
+ ]);
138
+ }
139
+ function validateArtistUpdate(record: AuditRecord): void {
140
+ switch (record.changed_fields?.[0]) {
141
+ case "status":
142
+ requireMemberActor(record);
143
+ return requireLifecycle(record);
144
+ case "gallery":
145
+ requireMemberActor(record);
146
+ return requireGallery(record);
147
+ case "participants":
148
+ requireMemberActor(record);
149
+ return requireParticipant(record);
150
+ case "share_links":
151
+ requireMemberActor(record);
152
+ return requireShareLink(record);
153
+ default:
154
+ throw new TypeError("artist update rejects variant");
155
+ }
156
+ }
157
+ function validateLabelUpdate(record: AuditRecord): void {
158
+ switch (record.changed_fields?.[0]) {
159
+ case "status":
160
+ requireMemberActor(record);
161
+ return requireLifecycle(record);
162
+ case "participants":
163
+ requireMemberActor(record);
164
+ return requireParticipant(record);
165
+ case "logo":
166
+ requireMemberActor(record);
167
+ return requireLogo(record);
168
+ case "share_links":
169
+ requireMemberActor(record);
170
+ return requireShareLink(record);
171
+ default:
172
+ throw new TypeError("label update rejects variant");
173
+ }
174
+ }
175
+
176
+ /** Returns true only for reviewed Artist and Label catalog actions. */
177
+ export function validateArtistLabelAuditRecord(record: AuditRecord): boolean {
178
+ if (!artistLabelActions.has(record.action as ArtistLabelAction)) return false;
179
+ requireCatalogTarget(record.action, record.target_type, record.target_id);
180
+ if (!record.action.endsWith(".updated")) {
181
+ requireNoExtra(record, []);
182
+ return true;
183
+ }
184
+ if (record.action === "artist.updated") validateArtistUpdate(record);
185
+ else validateLabelUpdate(record);
186
+ return true;
187
+ }
188
+
189
+ export function buildArtistCreatedAuditRecord(
190
+ metadata: AuditMetadata,
191
+ id: string,
192
+ ): AuditRecord {
193
+ return buildArtistLabelAuditRecord(metadata, "artist.created", id);
194
+ }
195
+ export function buildArtistDeletedAuditRecord(
196
+ metadata: AuditMetadata,
197
+ id: string,
198
+ ): AuditRecord {
199
+ return buildArtistLabelAuditRecord(metadata, "artist.deleted", id);
200
+ }
201
+ export function buildArtistLifecycleAuditRecord(
202
+ metadata: AuditMetadata,
203
+ id: string,
204
+ previousState: ArtistLabelLifecycleState,
205
+ newState: ArtistLabelLifecycleState,
206
+ ): AuditRecord {
207
+ return buildArtistLabelAuditRecord(metadata, "artist.updated", id, {
208
+ changed_fields: ["status"],
209
+ previous_state: previousState,
210
+ new_state: newState,
211
+ });
212
+ }
213
+ export function buildArtistGalleryAuditRecord(
214
+ metadata: AuditMetadata,
215
+ id: string,
216
+ fileIds: readonly string[],
217
+ ): AuditRecord {
218
+ return buildArtistLabelAuditRecord(metadata, "artist.updated", id, {
219
+ changed_fields: ["gallery"],
220
+ file_ids: [...fileIds],
221
+ });
222
+ }
223
+ export function buildArtistParticipantAuditRecord(
224
+ metadata: AuditMetadata,
225
+ id: string,
226
+ subjectMemberId: string,
227
+ previousRelationship: AuditRelationship,
228
+ newRelationship: AuditRelationship,
229
+ ): AuditRecord {
230
+ return buildArtistLabelAuditRecord(metadata, "artist.updated", id, {
231
+ changed_fields: ["participants"],
232
+ subject_member_id: subjectMemberId,
233
+ previous_relationship: previousRelationship,
234
+ new_relationship: newRelationship,
235
+ });
236
+ }
237
+ export function buildArtistShareLinkAuditRecord(
238
+ metadata: AuditMetadata,
239
+ id: string,
240
+ itemId: string,
241
+ itemOperation: ShareLinkOperation,
242
+ ): AuditRecord {
243
+ return buildArtistLabelAuditRecord(metadata, "artist.updated", id, {
244
+ changed_fields: ["share_links"],
245
+ item_id: itemId,
246
+ item_operation: itemOperation,
247
+ });
248
+ }
249
+ export function buildLabelCreatedAuditRecord(
250
+ metadata: AuditMetadata,
251
+ id: string,
252
+ ): AuditRecord {
253
+ return buildArtistLabelAuditRecord(metadata, "label.created", id);
254
+ }
255
+ export function buildLabelDeletedAuditRecord(
256
+ metadata: AuditMetadata,
257
+ id: string,
258
+ ): AuditRecord {
259
+ return buildArtistLabelAuditRecord(metadata, "label.deleted", id);
260
+ }
261
+ export function buildLabelLifecycleAuditRecord(
262
+ metadata: AuditMetadata,
263
+ id: string,
264
+ previousState: ArtistLabelLifecycleState,
265
+ newState: ArtistLabelLifecycleState,
266
+ ): AuditRecord {
267
+ return buildArtistLabelAuditRecord(metadata, "label.updated", id, {
268
+ changed_fields: ["status"],
269
+ previous_state: previousState,
270
+ new_state: newState,
271
+ });
272
+ }
273
+ export function buildLabelParticipantAuditRecord(
274
+ metadata: AuditMetadata,
275
+ id: string,
276
+ subjectMemberId: string,
277
+ previousRelationship: AuditRelationship,
278
+ newRelationship: AuditRelationship,
279
+ ): AuditRecord {
280
+ return buildArtistLabelAuditRecord(metadata, "label.updated", id, {
281
+ changed_fields: ["participants"],
282
+ subject_member_id: subjectMemberId,
283
+ previous_relationship: previousRelationship,
284
+ new_relationship: newRelationship,
285
+ });
286
+ }
287
+ export function buildLabelLogoAuditRecord(
288
+ metadata: AuditMetadata,
289
+ id: string,
290
+ assetSlot: AuditAssetSlot,
291
+ collectionOperation: AuditCollectionOperation,
292
+ assetId: string,
293
+ ): AuditRecord {
294
+ return buildArtistLabelAuditRecord(metadata, "label.updated", id, {
295
+ changed_fields: ["logo"],
296
+ asset_slot: assetSlot,
297
+ collection_operation: collectionOperation,
298
+ asset_id: assetId,
299
+ });
300
+ }
301
+ export function buildLabelShareLinkAuditRecord(
302
+ metadata: AuditMetadata,
303
+ id: string,
304
+ itemId: string,
305
+ itemOperation: ShareLinkOperation,
306
+ ): AuditRecord {
307
+ return buildArtistLabelAuditRecord(metadata, "label.updated", id, {
308
+ changed_fields: ["share_links"],
309
+ item_id: itemId,
310
+ item_operation: itemOperation,
311
+ });
312
+ }
@@ -0,0 +1,107 @@
1
+ import type { AuditRecord } from "../records.ts";
2
+
3
+ /**
4
+ * Canonical wire representation for set-shaped audit attributes. In
5
+ * particular, an empty input stays an explicit empty array rather than being
6
+ * omitted; validators decide whether a given catalog variant permits it.
7
+ */
8
+ export function canonicalAuditValues(
9
+ values: readonly string[],
10
+ ): readonly string[] {
11
+ return [...new Set(values)].sort();
12
+ }
13
+
14
+ /** The complete, reviewed Domain Audit JSONB attribute namespace. */
15
+ export const AUDIT_RECORD_ATTRIBUTE_NAMES = [
16
+ "nickname",
17
+ "asset_id",
18
+ "consent_id",
19
+ "changed_fields",
20
+ "collection_operation",
21
+ "asset_slot",
22
+ "previous_state",
23
+ "new_state",
24
+ "scheduled_at",
25
+ "scheduled_time_zone",
26
+ "version_id",
27
+ "version_number",
28
+ "contributor_member_ids",
29
+ "event_name",
30
+ "file_id",
31
+ "file_ids",
32
+ "item_id",
33
+ "item_ids",
34
+ "item_operation",
35
+ "item_scope",
36
+ "parent_id",
37
+ "policy_type",
38
+ "effective_at",
39
+ "post_ids",
40
+ "preferred_locale",
41
+ "locale",
42
+ "previous_locale",
43
+ "new_locale",
44
+ "previous_item_id",
45
+ "previous_item_ids",
46
+ "previous_parent_id",
47
+ "new_parent_id",
48
+ "previous_relationship",
49
+ "new_relationship",
50
+ "previous_series_id",
51
+ "new_series_id",
52
+ "subject_member_id",
53
+ "subject_post_id",
54
+ "tag_ids",
55
+ "tag_name",
56
+ "previous_role",
57
+ "new_role",
58
+ "email",
59
+ "previous_email",
60
+ "new_email",
61
+ "provider",
62
+ "provider_subject",
63
+ "passkey_ids",
64
+ "session_scope",
65
+ "session_ids",
66
+ ] as const;
67
+
68
+ export type AuditRecordAttributeName =
69
+ (typeof AUDIT_RECORD_ATTRIBUTE_NAMES)[number];
70
+
71
+ const auditRecordKeys = new Set<string>([
72
+ "audit_id",
73
+ "occurred_at",
74
+ "action",
75
+ "target_type",
76
+ "target_id",
77
+ "request_id",
78
+ "trace_id",
79
+ "span_id",
80
+ "actor_kind",
81
+ "actor_member_id",
82
+ "actor_service",
83
+ ...AUDIT_RECORD_ATTRIBUTE_NAMES,
84
+ ]);
85
+
86
+ /** Rejects input that cannot be represented by the public Audit wire type. */
87
+ export function assertKnownAuditRecordKeys(record: AuditRecord): void {
88
+ for (const key of Object.keys(record)) {
89
+ if (!auditRecordKeys.has(key)) {
90
+ throw new TypeError(`audit record has unknown attribute ${key}`);
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Enforces an action variant's exact permitted attribute shape. */
96
+ export function assertOnlyAuditAttributes(
97
+ record: AuditRecord,
98
+ allowed: readonly string[],
99
+ ): void {
100
+ for (const name of AUDIT_RECORD_ATTRIBUTE_NAMES) {
101
+ if (record[name] !== undefined && !allowed.includes(name)) {
102
+ throw new TypeError(
103
+ `audit action ${record.action} does not allow ${name}`,
104
+ );
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,25 @@
1
+ import { validateAuditRecord, type AuditRecord } from "../records.ts";
2
+ import type { AuditMetadata } from "./types.ts";
3
+
4
+ type AuditTarget = Pick<AuditRecord, "action" | "target_type" | "target_id">;
5
+
6
+ // A semantic builder may supply only reviewed domain attributes. Metadata and
7
+ // correlation are exclusively supplied by its caller, while action/target are
8
+ // exclusively supplied by the focused domain helper.
9
+ type AuditRecordAttributes = Omit<
10
+ AuditRecord,
11
+ keyof AuditMetadata | keyof AuditTarget
12
+ >;
13
+
14
+ /** Builds only a fully validated, catalogued audit wire record. */
15
+ // Internal assembly point used only by focused semantic builder modules.
16
+ // It is intentionally not re-exported from the package facade.
17
+ export function buildAuditRecord(
18
+ metadata: AuditMetadata,
19
+ target: AuditTarget,
20
+ attributes: AuditRecordAttributes,
21
+ ): AuditRecord {
22
+ const record = { ...attributes, ...metadata, ...target } as AuditRecord;
23
+ validateAuditRecord(record);
24
+ return record;
25
+ }