@company-semantics/contracts 31.0.0 → 33.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.
@@ -64,6 +64,41 @@ function toEmail(subject: string, blocks: Block[]): RenderedEmail {
64
64
  return { subject, text: textShell(blocks), html: htmlShell(blocks) };
65
65
  }
66
66
 
67
+ /**
68
+ * Fill `{field}` placeholders in a registry subject from the payload (e.g.
69
+ * `{orgName}` → the payload's orgName). Unmatched/absent placeholders are left
70
+ * verbatim, and a placeholder-free subject passes through untouched.
71
+ */
72
+ function resolveSubject(template: string, payload: unknown): string {
73
+ if (!payload || typeof payload !== "object") return template;
74
+ const data = payload as Record<string, unknown>;
75
+ return template.replace(/\{(\w+)\}/g, (whole, key) =>
76
+ data[key] != null ? String(data[key]) : whole,
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Per-kind subject data. Most kinds resolve directly against the payload; a few
82
+ * expose a derived token the payload doesn't carry. `org.unit_owner_granted`
83
+ * needs a short role word ("owner"/"delegate") for the subject, whereas the
84
+ * payload's `roleLabel` is the display form ("Unit owner"/"Delegate") used in
85
+ * the body's Role row — so it's derived here rather than mutating roleLabel.
86
+ */
87
+ function subjectData(kind: EmailKind, payload: unknown): unknown {
88
+ if (
89
+ kind === "org.unit_owner_granted" &&
90
+ payload &&
91
+ typeof payload === "object"
92
+ ) {
93
+ const roleLabel = (payload as { roleLabel?: string }).roleLabel;
94
+ return {
95
+ ...payload,
96
+ roleWord: roleLabel === "Delegate" ? "delegate owner" : "owner",
97
+ };
98
+ }
99
+ return payload;
100
+ }
101
+
67
102
  /**
68
103
  * Render an email by kind. Subject comes from `EMAIL_KINDS`; both surfaces
69
104
  * derive from one composed block list. Accepts the full `EmailKind` union so
@@ -77,7 +112,10 @@ export function renderEmail<K extends EmailKind>(
77
112
  payload: PayloadFor<K>,
78
113
  options?: RenderEmailOptions,
79
114
  ): RenderedEmail {
80
- const subject = EMAIL_KINDS[kind].subject;
115
+ const subject = resolveSubject(
116
+ EMAIL_KINDS[kind].subject,
117
+ subjectData(kind, payload),
118
+ );
81
119
 
82
120
  switch (kind) {
83
121
  case "auth.otp":
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Security alert email (plain text only). Signs off with a "Security" signer.
2
+ * Security alert email (plain text only).
3
3
  */
4
4
 
5
5
  import type { EmailPayloads } from "../types";
6
6
 
7
- import { type Block, keyValue, paragraph, signature } from "./blocks";
8
- import { COMPANY_NAME } from "./constants";
7
+ import { type Block, keyValue, paragraph, security, signature } from "./blocks";
9
8
 
10
9
  export type SecurityAlertPayload = EmailPayloads["security.alert"];
11
10
  export type SecurityAlertType = SecurityAlertPayload["alertType"];
@@ -18,19 +17,17 @@ export const SECURITY_ALERT_TYPES = [
18
17
  export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
19
18
  const { alertType, details, timestamp } = payload;
20
19
 
21
- const blocks: Block[] = [];
20
+ const blocks: Block[] = [security()];
22
21
 
23
22
  switch (alertType) {
24
23
  case "excessive_otp_requests":
25
24
  blocks.push(
26
- paragraph(
27
- "We detected an unusual number of login code requests for your account.",
28
- ),
25
+ paragraph("Unusual login-code activity detected."),
29
26
  keyValue("Details", details, "normal"),
30
27
  paragraph("If this was you, no action is needed.", "tight"),
31
28
  paragraph(
32
29
  "If you didn't request these codes, someone may be trying to access your account.",
33
- "tight",
30
+ "normal",
34
31
  ),
35
32
  paragraph("We recommend reviewing your account security."),
36
33
  );
@@ -38,12 +35,14 @@ export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
38
35
 
39
36
  case "unusual_login_location":
40
37
  blocks.push(
41
- paragraph("We detected a login attempt from an unusual location."),
38
+ paragraph("A login from an unusual location was detected."),
42
39
  keyValue("Details", details, "normal"),
43
40
  paragraph("If this was you, no action is needed.", "tight"),
44
41
  paragraph(
45
- "If you didn't attempt to log in, please secure your account immediately.",
42
+ "If you didn't attempt to log in, someone may be trying to access your account.",
43
+ "normal",
46
44
  ),
45
+ paragraph("We recommend reviewing your account security."),
47
46
  );
48
47
  break;
49
48
 
@@ -54,8 +53,8 @@ export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
54
53
  }
55
54
 
56
55
  blocks.push(
57
- keyValue("Time", new Date(timestamp).toUTCString(), "normal"),
58
- signature(`${COMPANY_NAME} Security`),
56
+ keyValue("Time", new Date(timestamp).toUTCString(), "none"),
57
+ signature(),
59
58
  );
60
59
 
61
60
  return blocks;
@@ -7,16 +7,14 @@ import type { EmailPayloads } from "../types";
7
7
  import {
8
8
  ACCESS_PHRASE,
9
9
  type Block,
10
- bold,
11
- chatUser,
12
- ctaBox,
13
10
  footer,
14
11
  greeting,
15
- type Inline,
12
+ keyValue,
16
13
  NOTICE,
17
14
  paragraph,
18
15
  signature,
19
16
  } from "./blocks";
17
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
20
18
  import { COMPANY_NAME } from "./constants";
21
19
 
22
20
  export type ShareGrantedPayload = EmailPayloads["share.granted"];
@@ -32,25 +30,21 @@ export function renderShareGranted(payload: ShareGrantedPayload): Block[] {
32
30
  message,
33
31
  } = payload;
34
32
 
35
- const entity: Array<string | Inline> = entityTitle
36
- ? [`the ${entityLabel} `, bold(`"${entityTitle}"`)]
37
- : [`a ${entityLabel}`];
38
-
39
33
  const blocks: Block[] = [
40
34
  greeting(recipientName),
41
- paragraph([
42
- bold(granterName),
43
- " shared ",
44
- ...entity,
45
- ` with you — you ${ACCESS_PHRASE[accessLevel]}.`,
46
- ]),
35
+ paragraph(`A ${entityLabel} was shared with you.`),
36
+ chatUnit(
37
+ message ? chatUser(message, granterName) : chatAssistant("Open to view."),
38
+ chatCta({ label: "OPEN", href: ctaUrl }),
39
+ ),
40
+ keyValue("From", granterName),
47
41
  ];
48
42
 
49
- if (message) blocks.push(chatUser(message, granterName));
43
+ if (entityTitle) blocks.push(keyValue("Item", `"${entityTitle}"`));
50
44
 
51
45
  blocks.push(
52
- ctaBox({ label: "OPEN", href: ctaUrl }),
53
- footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
46
+ keyValue("Access", ACCESS_PHRASE[accessLevel], "normal"),
47
+ footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
54
48
  signature(),
55
49
  );
56
50
 
@@ -6,15 +6,16 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- bold,
10
- chatUser,
11
- ctaBox,
12
9
  footer,
10
+ formatExpiry,
13
11
  greeting,
12
+ keyValue,
14
13
  NOTICE,
15
14
  paragraph,
16
15
  signature,
16
+ titleCase,
17
17
  } from "./blocks";
18
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
18
19
  import { COMPANY_NAME } from "./constants";
19
20
 
20
21
  export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
@@ -22,28 +23,38 @@ export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
22
23
  export function renderUnitOwnerGranted(
23
24
  payload: UnitOwnerGrantedPayload,
24
25
  ): Block[] {
25
- const { granterName, recipientName, unitName, roleLabel, ctaUrl, message } =
26
- payload;
27
-
28
- const blocks: Block[] = [
26
+ const {
27
+ granterName,
28
+ recipientName,
29
+ unitName,
30
+ roleLabel,
31
+ ctaUrl,
32
+ message,
33
+ expiresAt,
34
+ } = payload;
35
+ const roleNoun = roleLabel === "Delegate" ? "delegate owner" : "unit owner";
36
+
37
+ return [
29
38
  greeting(recipientName),
30
- paragraph([
31
- bold(granterName),
32
- " added you as a ",
33
- bold(roleLabel),
34
- " of ",
35
- bold(unitName),
36
- ".",
37
- ]),
38
- ];
39
-
40
- if (message) blocks.push(chatUser(message, granterName));
41
-
42
- blocks.push(
43
- ctaBox({ label: "VIEW TEAM", href: ctaUrl }),
44
- footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
39
+ paragraph(
40
+ `You are now a ${roleNoun} of ${unitName}. ${roleNoun[0].toUpperCase()}${roleNoun.slice(1)}s hold authority over the team: managing team membership, doc access, strategy, and execution against goals.`,
41
+ ),
42
+ chatUnit(
43
+ ...(message ? [chatUser(message, granterName)] : []),
44
+ chatAssistant("Open the team to get started."),
45
+ chatCta({ label: "MANAGE TEAM", href: ctaUrl }),
46
+ ),
47
+ keyValue("From", granterName),
48
+ keyValue("Team", unitName),
49
+ keyValue(
50
+ "Role",
51
+ titleCase(roleLabel),
52
+ expiresAt != null ? "tight" : "normal",
53
+ ),
54
+ ...(expiresAt != null
55
+ ? [keyValue("Expires", formatExpiry(expiresAt), "normal")]
56
+ : []),
57
+ footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
45
58
  signature(),
46
- );
47
-
48
- return blocks;
59
+ ];
49
60
  }
@@ -63,13 +63,16 @@ export interface EmailPayloads {
63
63
  orgName: string;
64
64
  role: "admin" | "member";
65
65
  acceptUrl: string;
66
- expiresInDays: number;
66
+ /** ISO timestamp when the invitation expires. Rendered as "Expires: Jun 13, 2026". */
67
+ expiresAt: string;
67
68
  };
68
69
  "org.unit_owner_granted": {
69
70
  /** Display name of the person who granted access */
70
71
  granterName: string;
71
72
  /** Display name of the recipient (optional; falls back to a neutral greeting) */
72
73
  recipientName?: string;
74
+ /** Name of the organization / workspace (rendered in the subject) */
75
+ orgName: string;
73
76
  /** Name of the org unit / team the access applies to */
74
77
  unitName: string;
75
78
  /** Human-readable role label the recipient was given */
@@ -78,16 +81,20 @@ export interface EmailPayloads {
78
81
  ctaUrl: string;
79
82
  /** Optional message from the granter, shown in the email and recorded with the grant */
80
83
  message?: string;
84
+ /** ISO timestamp when the grant expires. Absent = permanent (formal unit owners; delegations with no expiry). Rendered as the last key/value row ("Expires: Jun 13, 2026") when present. */
85
+ expiresAt?: string;
81
86
  };
82
87
  "org.ownership_transfer": {
83
88
  /** Name of the organization being transferred (subject is static; org name renders in the body) */
84
89
  orgName: string;
85
90
  /** Full URL to accept the transfer (token-bearing) */
86
91
  acceptUrl: string;
87
- /** How long until the invitation expires */
88
- expiresInDays: number;
92
+ /** ISO timestamp when the invitation expires. Rendered as "Expires: Jun 13, 2026". */
93
+ expiresAt: string;
89
94
  /** Optional message from the current owner */
90
95
  note?: string;
96
+ /** Display name of the workspace owner who initiated the transfer; attributes the note bubble. Omitted for admin-initiated transfers (no workspace-owner author). */
97
+ fromName?: string;
91
98
  };
92
99
  "org.ownership_transfer_completed": {
93
100
  /** Name of the organization that was transferred */
@@ -221,7 +221,21 @@ export interface CompanyMdDocCore extends CompanyMdNodeIdentity {
221
221
  }
222
222
 
223
223
  export interface CompanyMdDocCollaborators {
224
- readonly owner: { readonly id: string; readonly name: string } | null;
224
+ /**
225
+ * The doc's EFFECTIVE owners (ADR-CONTRACTS-084) — hand-mirror of
226
+ * `CompanyMdDocResponseSchema.owners`; keep the two in lockstep.
227
+ *
228
+ * A list, not a scalar: ownership is resolved rather than stored (a personal
229
+ * owner, else the owning unit's live authority holders, else the org account
230
+ * owner), and co-ownership is first-class (ADR-CTRL-161) — every entry is
231
+ * owner-equivalent (ADR-BE-189). Name one owner with `owners[0]` and derive
232
+ * the co-owner count from `owners.length`. Empty only when the org has no
233
+ * account owner (runtime-impossible).
234
+ */
235
+ readonly owners: ReadonlyArray<{
236
+ readonly id: string;
237
+ readonly name: string;
238
+ }>;
225
239
  readonly canEdit: boolean;
226
240
  readonly members: ReadonlyArray<{
227
241
  readonly id: string;
@@ -13,6 +13,15 @@ import { z } from "zod";
13
13
  import { AccessLevelSchema } from "./access-levels";
14
14
  import { AccessSourceSchema } from "./access-source";
15
15
 
16
+ /**
17
+ * Contract-level invariant: an id set contains each id at most once. A duplicate
18
+ * is a serializer bug, not a presentation concern, so we fail Zod parse at the
19
+ * API boundary on both server and client (mirrors `uniqueByUserId` on
20
+ * OrgUnitOwnersResponseSchema).
21
+ */
22
+ const uniqueValues = (arr: ReadonlyArray<string>): boolean =>
23
+ new Set(arr).size === arr.length;
24
+
16
25
  export const PrincipalTypeSchema = z.enum(["user", "unit", "org"]);
17
26
  export type PrincipalType = z.infer<typeof PrincipalTypeSchema>;
18
27
 
@@ -61,7 +70,19 @@ export type AclGrantResponse = z.infer<typeof AclGrantResponseSchema>;
61
70
  export const AclListResponseSchema = z.object({
62
71
  entity_type: z.string(),
63
72
  entity_id: z.string().uuid(),
64
- owner_user_id: z.string().uuid().nullable(),
73
+ /**
74
+ * The entity's EFFECTIVE owners (ADR-CONTRACTS-084). Ownership is resolved,
75
+ * not stored: a personal doc yields its stored owner; a structural company.md
76
+ * doc yields its owning unit's live authority holders; the org account owner
77
+ * is the guaranteed fallback.
78
+ *
79
+ * A set, not a scalar — co-ownership is first-class (ADR-CTRL-161), so a unit
80
+ * with co-owners yields > 1 and EVERY entry is owner-equivalent (ADR-BE-189).
81
+ * Empty only when the org has no account owner (runtime-impossible).
82
+ */
83
+ owner_user_ids: z
84
+ .array(z.string().uuid())
85
+ .refine(uniqueValues, { message: "duplicate id in owner_user_ids" }),
65
86
  /** General-access scope: WHO is eligible (the discoverability axis). */
66
87
  visibility: EntityVisibilitySchema,
67
88
  /**