@company-semantics/contracts 33.0.0 → 35.0.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 (78) hide show
  1. package/package.json +4 -4
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +2 -2
  4. package/src/index.ts +17 -11
  5. package/src/notifications/README.md +140 -0
  6. package/src/notifications/__tests__/README.md +54 -0
  7. package/src/notifications/__tests__/__snapshots__/README.md +32 -0
  8. package/src/notifications/__tests__/content.test.ts +186 -0
  9. package/src/notifications/__tests__/context.test.ts +72 -0
  10. package/src/notifications/__tests__/definition.test.ts +222 -0
  11. package/src/notifications/__tests__/kinds.test.ts +81 -0
  12. package/src/notifications/__tests__/output-parity.golden.ts +363 -0
  13. package/src/notifications/__tests__/output-parity.test.ts +122 -0
  14. package/src/notifications/__tests__/registry.test.ts +184 -0
  15. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
  16. package/src/notifications/__tests__/renderer.test.ts +181 -0
  17. package/src/notifications/content.ts +249 -0
  18. package/src/notifications/context.ts +70 -0
  19. package/src/notifications/definition.ts +82 -0
  20. package/src/notifications/index.ts +104 -0
  21. package/src/notifications/kinds/README.md +57 -0
  22. package/src/notifications/kinds/auth-otp.ts +99 -0
  23. package/src/notifications/kinds/chat-shared.ts +56 -0
  24. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  25. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  26. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  27. package/src/notifications/kinds/index.ts +19 -0
  28. package/src/notifications/kinds/org-invite.ts +62 -0
  29. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  30. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  31. package/src/notifications/kinds/security-alert.ts +78 -0
  32. package/src/notifications/kinds/share-granted.ts +74 -0
  33. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  34. package/src/notifications/kinds.ts +68 -0
  35. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  36. package/src/notifications/registry.ts +107 -0
  37. package/src/notifications/render.ts +106 -0
  38. package/src/notifications/renderer.ts +50 -0
  39. package/src/notifications/renderers/README.md +50 -0
  40. package/src/notifications/renderers/email/README.md +74 -0
  41. package/src/notifications/renderers/email/__tests__/README.md +29 -0
  42. package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
  43. package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
  44. package/src/notifications/renderers/email/constants.ts +47 -0
  45. package/src/notifications/renderers/email/cta.ts +63 -0
  46. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  47. package/src/notifications/renderers/email/index.ts +73 -0
  48. package/src/notifications/renderers/email/render.ts +225 -0
  49. package/src/notifications/renderers/email/shells.ts +60 -0
  50. package/src/notifications/renderers/slack/README.md +73 -0
  51. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  52. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  53. package/src/notifications/renderers/slack/index.ts +261 -0
  54. package/src/notifications/renderers/sms/README.md +69 -0
  55. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  56. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  57. package/src/notifications/renderers/sms/index.ts +131 -0
  58. package/src/notifications/text.ts +52 -0
  59. package/src/email/README.md +0 -51
  60. package/src/email/__tests__/registry.test.ts +0 -161
  61. package/src/email/index.ts +0 -36
  62. package/src/email/registry.ts +0 -155
  63. package/src/email/render/auth-otp.ts +0 -71
  64. package/src/email/render/blocks.ts +0 -281
  65. package/src/email/render/chat-shared.ts +0 -35
  66. package/src/email/render/company-md-access-approved.ts +0 -42
  67. package/src/email/render/company-md-access-denied.ts +0 -43
  68. package/src/email/render/company-md-access-requested.ts +0 -46
  69. package/src/email/render/constants.ts +0 -18
  70. package/src/email/render/index.ts +0 -58
  71. package/src/email/render/org-invite.ts +0 -40
  72. package/src/email/render/ownership-transfer-completed.ts +0 -41
  73. package/src/email/render/ownership-transfer.ts +0 -42
  74. package/src/email/render/render-email.ts +0 -194
  75. package/src/email/render/security-alert.ts +0 -61
  76. package/src/email/render/share-granted.ts +0 -52
  77. package/src/email/render/unit-owner-granted.ts +0 -60
  78. /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `compose` is where the whole layer's purity claim lives (ADR-CONTRACTS-085):
3
+ * everything above it is fact, everything below is one channel's opinion. These
4
+ * tests hold it to the four promises a type cannot express.
5
+ *
6
+ * PURITY — the same `(payload, context)` composes the same content, twice, with
7
+ * the clock moved between. The old `blocks.signature()` read
8
+ * `new Date().getFullYear()` mid-render, which is why every render test used to
9
+ * freeze time; the point of `RenderContext` is that these tests do not have to.
10
+ * Moving the clock and getting identical content is the proof that the ambient
11
+ * read is really gone.
12
+ *
13
+ * THE BRAND SEAM — the ten kinds that footer "sent via Company Semantics" must
14
+ * read that name from `context.brand.name`, not from an imported constant. A
15
+ * test that only ever renders the default context cannot tell the difference, so
16
+ * these override the brand and assert the override lands.
17
+ *
18
+ * THE PII GATE — the old `renderAuthOtp` took an `includeRequestMetadata` option
19
+ * and the backend passed an env flag. `compose` has no such parameter: the gate
20
+ * is whether the payload carries the fields. That relocation is a
21
+ * privacy-relevant behaviour change and is pinned here rather than left to the
22
+ * email wiring to discover.
23
+ *
24
+ * CONTENT, NOT MARKUP — the elements carry no HTML. The cheapest possible check
25
+ * for the mistake this layer exists to prevent.
26
+ */
27
+
28
+ import { describe, expect, it } from "vitest";
29
+
30
+ import type { NotificationContent, NotificationElement } from "../content";
31
+ import { createRenderContext } from "../context";
32
+ import type { NotificationKind } from "../kinds";
33
+ import { NOTIFICATION_DEFINITIONS } from "../registry";
34
+ import { OUTPUT_PARITY_FIXTURES } from "./output-parity.golden";
35
+
36
+ const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
37
+
38
+ /** Compose a kind against its first parity fixture. */
39
+ function compose(
40
+ kind: NotificationKind,
41
+ context = CONTEXT,
42
+ ): NotificationContent {
43
+ const fixture = OUTPUT_PARITY_FIXTURES.find((f) => f.kind === kind);
44
+ if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
45
+ const fn = NOTIFICATION_DEFINITIONS[kind].compose as (
46
+ p: unknown,
47
+ c: typeof context,
48
+ ) => NotificationContent;
49
+ return fn(fixture.payload, context);
50
+ }
51
+
52
+ const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
53
+
54
+ /** Every element of a composed notification, flattened across its sections. */
55
+ function elementsOf(content: NotificationContent): NotificationElement[] {
56
+ return content.sections.flatMap((s) => s.elements);
57
+ }
58
+
59
+ describe("compose purity", () => {
60
+ it("is deterministic for the same payload and context", () => {
61
+ for (const kind of KINDS) {
62
+ expect(compose(kind)).toEqual(compose(kind));
63
+ }
64
+ });
65
+
66
+ it("does not read the clock", () => {
67
+ // The impurity this layer retired: the old `blocks.signature()` called
68
+ // `new Date().getFullYear()` mid-render, so its render tests had to freeze
69
+ // time to stay stable. These deliberately do not.
70
+ //
71
+ // The stub fakes ONLY the argless `new Date()` and `Date.now()` — the clock.
72
+ // `new Date(iso)` is left alone because parsing a timestamp the PAYLOAD
73
+ // carries is pure, and two composes rely on it: `formatExpiry` and
74
+ // security.alert's `Time` row. Faking those too would fail the honest code.
75
+ const realDate = Date;
76
+ const before = KINDS.map((kind) => compose(kind));
77
+ try {
78
+ globalThis.Date = class extends realDate {
79
+ constructor(...args: ConstructorParameters<DateConstructor>) {
80
+ if (args.length === 0) super("2099-01-01T00:00:00.000Z");
81
+ else super(...args);
82
+ }
83
+ static now(): number {
84
+ return new realDate("2099-01-01T00:00:00.000Z").getTime();
85
+ }
86
+ } as DateConstructor;
87
+
88
+ expect(KINDS.map((kind) => compose(kind))).toEqual(before);
89
+ } finally {
90
+ globalThis.Date = realDate;
91
+ }
92
+ });
93
+
94
+ it("emits no year — the copyright year is the renderer's, from the context", () => {
95
+ // The old `signature()` baked the year into its bytes. Here `signature` is a
96
+ // fieldless element and the year arrives at render time, which is what makes
97
+ // a composed notification safe to cache, log or compare across a New Year.
98
+ for (const kind of KINDS) {
99
+ const signatures = elementsOf(compose(kind)).filter(
100
+ (e) => e.type === "signature",
101
+ );
102
+ expect(signatures).toEqual([{ type: "signature" }]);
103
+ }
104
+ });
105
+ });
106
+
107
+ describe("compose reads ambient state from the context", () => {
108
+ it("takes the brand name from context.brand, not an imported constant", () => {
109
+ const context = createRenderContext({
110
+ brand: { name: "Umbrella Corp", copyrightYear: 2026 },
111
+ });
112
+
113
+ // Ten of the eleven kinds footer "sent via <brand>"; security.alert has no
114
+ // notice at all, on purpose.
115
+ const branded = KINDS.filter((kind) => kind !== "security.alert");
116
+ expect(branded).toHaveLength(10);
117
+
118
+ for (const kind of branded) {
119
+ const notices = elementsOf(compose(kind, context)).filter(
120
+ (e) => e.type === "notice",
121
+ );
122
+
123
+ expect(notices).toHaveLength(1);
124
+ expect(notices[0].lines[0]).toContain("Umbrella Corp");
125
+ expect(notices[0].lines[0]).not.toContain("Company Semantics");
126
+ }
127
+ });
128
+
129
+ it("still says Company Semantics under an un-overridden context", () => {
130
+ // The default must reproduce today's bytes, or the parity proof moves.
131
+ const notice = elementsOf(compose("org.invite")).find(
132
+ (e) => e.type === "notice",
133
+ );
134
+ expect(notice?.lines).toEqual([
135
+ "This invitation was sent via Company Semantics.",
136
+ "If you weren't expecting this, no action is required.",
137
+ ]);
138
+ });
139
+ });
140
+
141
+ describe("compose gates PII on the payload, not on an option", () => {
142
+ const otpCompose = NOTIFICATION_DEFINITIONS["auth.otp"].compose;
143
+
144
+ it("omits request details when the payload carries none", () => {
145
+ const content = otpCompose(
146
+ { otp: "123456", expiresInMinutes: 10 },
147
+ CONTEXT,
148
+ );
149
+
150
+ expect(elementsOf(content).map((e) => e.type)).not.toContain("metadata");
151
+ });
152
+
153
+ it("shows request details when the payload carries them", () => {
154
+ const content = otpCompose(
155
+ {
156
+ otp: "246810",
157
+ expiresInMinutes: 5,
158
+ requestIp: "203.0.113.4",
159
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
160
+ },
161
+ CONTEXT,
162
+ );
163
+
164
+ const metadata = elementsOf(content).find((e) => e.type === "metadata");
165
+ expect(metadata).toEqual({
166
+ type: "metadata",
167
+ label: "Request details",
168
+ rows: [
169
+ { label: "IP address", value: "203.0.113.4" },
170
+ {
171
+ label: "Device",
172
+ value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
173
+ },
174
+ ],
175
+ });
176
+ });
177
+
178
+ it("truncates an overlong user agent, as the email layer did", () => {
179
+ const content = otpCompose(
180
+ { otp: "246810", expiresInMinutes: 5, userAgent: "U".repeat(120) },
181
+ CONTEXT,
182
+ );
183
+
184
+ const metadata = elementsOf(content).find((e) => e.type === "metadata");
185
+ expect(metadata).toEqual({
186
+ type: "metadata",
187
+ label: "Request details",
188
+ rows: [{ label: "Device", value: `${"U".repeat(77)}...` }],
189
+ });
190
+ });
191
+
192
+ it("shows the code itself as a call to action with no href", () => {
193
+ // `href` absent means the label IS the payload. A renderer must not
194
+ // fabricate a destination for an OTP.
195
+ const chatUnit = elementsOf(
196
+ otpCompose({ otp: "123456", expiresInMinutes: 10 }, CONTEXT),
197
+ ).find((e) => e.type === "chatUnit");
198
+
199
+ expect(chatUnit?.items).toContainEqual({
200
+ type: "callToAction",
201
+ label: "123456",
202
+ });
203
+ });
204
+ });
205
+
206
+ describe("compose emits content, not markup", () => {
207
+ it("puts no HTML in any element of any kind", () => {
208
+ for (const kind of KINDS) {
209
+ const serialized = JSON.stringify(compose(kind));
210
+ expect(serialized).not.toMatch(/<\/?[a-z]+[\s>]/i);
211
+ expect(serialized).not.toContain("&#39;");
212
+ }
213
+ });
214
+
215
+ it("carries no spacing — that is a channel's answer, not the notification's", () => {
216
+ for (const kind of KINDS) {
217
+ for (const element of elementsOf(compose(kind))) {
218
+ expect(element).not.toHaveProperty("spacing");
219
+ }
220
+ }
221
+ });
222
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `NotificationKind` is the migration's central claim: that the eleven things
3
+ * the email layer could actually SAY are notifications, not emails
4
+ * (ADR-CONTRACTS-085).
5
+ *
6
+ * WHAT THESE TESTS USED TO DO, AND WHY THEY NO LONGER DO IT. Membership was
7
+ * asserted against `IMPLEMENTED_EMAIL_KINDS`, and the union's deliberate
8
+ * divergence against `EmailKind` — one live union checked against another. Both
9
+ * sides of both comparisons were `src/email/`'s, and that directory is now
10
+ * deleted (ADR-CONTRACTS-086). Those assertions were not rewired, because there
11
+ * is nothing to rewire them to: an assertion with one side left is not a weaker
12
+ * version of itself, it is a tautology that reads like coverage. They were
13
+ * removed rather than allowed to pass vacuously.
14
+ *
15
+ * What still holds the membership claim, in the order it bites:
16
+ * - the `Record<NotificationKind, true>` below, which fails to COMPILE if a
17
+ * union member is added without being named here;
18
+ * - the frozen list in the first test, which fails at runtime if one is removed;
19
+ * - `./registry.test.ts`, which pins a definition for every member;
20
+ * - `./output-parity.test.ts`, which pins golden bytes — captured from the old
21
+ * layer — for every member. That is what now carries "these are exactly the
22
+ * kinds email could render": every kind here has bytes that layer produced.
23
+ *
24
+ * The DIVERGENCE is the easiest thing in this change to mistake for an omission,
25
+ * so it keeps a test of its own. `auth.magic_link` was a registered subject with
26
+ * no template: the old `renderEmail` accepted it and threw. Excluding it is what
27
+ * makes the notification registry total, so its absence is asserted on purpose —
28
+ * restoring it "for symmetry" would put the runtime hole back.
29
+ */
30
+
31
+ import { describe, expect, it } from "vitest";
32
+
33
+ import type { NotificationKind } from "../kinds";
34
+
35
+ /**
36
+ * Every notification kind, exhaustively. The `Record` type documents the lock; a
37
+ * new union member must be named here. The assertions below are what enforce it
38
+ * at runtime — `src/tsconfig.json` excludes test files, so the type alone would
39
+ * not fail a build.
40
+ */
41
+ const ALL_NOTIFICATION_KINDS: Record<NotificationKind, true> = {
42
+ "auth.otp": true,
43
+ "org.invite": true,
44
+ "org.unit_owner_granted": true,
45
+ "org.ownership_transfer": true,
46
+ "org.ownership_transfer_completed": true,
47
+ "security.alert": true,
48
+ "chat.shared": true,
49
+ "share.granted": true,
50
+ "companyMd.access_requested": true,
51
+ "companyMd.access_request_approved": true,
52
+ "companyMd.access_request_denied": true,
53
+ };
54
+
55
+ const KINDS = Object.keys(ALL_NOTIFICATION_KINDS).sort();
56
+
57
+ describe("NotificationKind", () => {
58
+ it("has exactly the eleven kinds with something to say", () => {
59
+ expect(KINDS).toEqual([
60
+ "auth.otp",
61
+ "chat.shared",
62
+ "companyMd.access_request_approved",
63
+ "companyMd.access_request_denied",
64
+ "companyMd.access_requested",
65
+ "org.invite",
66
+ "org.ownership_transfer",
67
+ "org.ownership_transfer_completed",
68
+ "org.unit_owner_granted",
69
+ "security.alert",
70
+ "share.granted",
71
+ ]);
72
+ });
73
+
74
+ it("excludes auth.magic_link, the one kind email registered without implementing", () => {
75
+ // Deliberate, not an oversight: `renderEmail("auth.magic_link", …)`
76
+ // type-checked and threw. A kind earns membership by having something to
77
+ // say, not by having a subject reserved for it. `./registry.test.ts` asserts
78
+ // `isValidNotificationKind` rejects it at the runtime boundary too.
79
+ expect(KINDS).not.toContain("auth.magic_link");
80
+ });
81
+ });