@company-semantics/contracts 55.0.0 → 56.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "55.0.0",
3
+ "version": "56.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '6eb28e0d82f9' as const;
3
- export const SPEC_HASH_FULL = '6eb28e0d82f9d1d88e4fc954ece5377c85ddd6da30b6171c4d2aa75f347c09ed' as const;
2
+ export const SPEC_HASH = '373ff35b2847' as const;
3
+ export const SPEC_HASH_FULL = '373ff35b284714e9030fd0c611b3d4c1be255cd12df7f6adec2b6f7e6ba9179f' as const;
@@ -926,6 +926,23 @@ export interface paths {
926
926
  patch?: never;
927
927
  trace?: never;
928
928
  };
929
+ "/api/workspace/invites/batch": {
930
+ parameters: {
931
+ query?: never;
932
+ header?: never;
933
+ path?: never;
934
+ cookie?: never;
935
+ };
936
+ get?: never;
937
+ put?: never;
938
+ /** Invite many directory people at once */
939
+ post: operations["batchInvite"];
940
+ delete?: never;
941
+ options?: never;
942
+ head?: never;
943
+ patch?: never;
944
+ trace?: never;
945
+ };
929
946
  "/api/workspace/invites/accept": {
930
947
  parameters: {
931
948
  query?: never;
@@ -977,6 +994,23 @@ export interface paths {
977
994
  patch?: never;
978
995
  trace?: never;
979
996
  };
997
+ "/api/workspace/directory": {
998
+ parameters: {
999
+ query?: never;
1000
+ header?: never;
1001
+ path?: never;
1002
+ cookie?: never;
1003
+ };
1004
+ /** List the org people directory */
1005
+ get: operations["listOrgDirectory"];
1006
+ put?: never;
1007
+ post?: never;
1008
+ delete?: never;
1009
+ options?: never;
1010
+ head?: never;
1011
+ patch?: never;
1012
+ trace?: never;
1013
+ };
980
1014
  "/api/workspace/domains": {
981
1015
  parameters: {
982
1016
  query?: never;
@@ -4577,6 +4611,26 @@ export interface components {
4577
4611
  contributesTo: string[];
4578
4612
  };
4579
4613
  };
4614
+ BatchInviteResponse: {
4615
+ invited: string[];
4616
+ skipped: {
4617
+ /** Format: uuid */
4618
+ personId: string;
4619
+ /** @enum {string} */
4620
+ reason: "already-member" | "invite-pending" | "no-email" | "send-failed";
4621
+ }[];
4622
+ failed: {
4623
+ /** Format: uuid */
4624
+ personId: string;
4625
+ /** @enum {string} */
4626
+ reason: "already-member" | "invite-pending" | "no-email" | "send-failed";
4627
+ }[];
4628
+ };
4629
+ BatchInviteRequest: {
4630
+ personIds: string[];
4631
+ /** @enum {string} */
4632
+ role: "admin" | "member";
4633
+ };
4580
4634
  InviteListResponse: {
4581
4635
  id: string;
4582
4636
  orgId: string;
@@ -4625,6 +4679,15 @@ export interface components {
4625
4679
  status: string;
4626
4680
  orgName?: string;
4627
4681
  };
4682
+ OrgDirectoryResponse: {
4683
+ people: {
4684
+ /** Format: uuid */
4685
+ personId: string;
4686
+ displayName: string;
4687
+ primaryEmail: string | null;
4688
+ userId: string | null;
4689
+ }[];
4690
+ };
4628
4691
  DomainListResponse: {
4629
4692
  id: string;
4630
4693
  orgId: string;
@@ -8230,6 +8293,30 @@ export interface operations {
8230
8293
  };
8231
8294
  };
8232
8295
  };
8296
+ batchInvite: {
8297
+ parameters: {
8298
+ query?: never;
8299
+ header?: never;
8300
+ path?: never;
8301
+ cookie?: never;
8302
+ };
8303
+ requestBody: {
8304
+ content: {
8305
+ "application/json": components["schemas"]["BatchInviteRequest"];
8306
+ };
8307
+ };
8308
+ responses: {
8309
+ /** @description Per-person outcome; partial success is a normal result */
8310
+ 200: {
8311
+ headers: {
8312
+ [name: string]: unknown;
8313
+ };
8314
+ content: {
8315
+ "application/json": components["schemas"]["BatchInviteResponse"];
8316
+ };
8317
+ };
8318
+ };
8319
+ };
8233
8320
  acceptInvite: {
8234
8321
  parameters: {
8235
8322
  query?: never;
@@ -8296,6 +8383,26 @@ export interface operations {
8296
8383
  };
8297
8384
  };
8298
8385
  };
8386
+ listOrgDirectory: {
8387
+ parameters: {
8388
+ query?: never;
8389
+ header?: never;
8390
+ path?: never;
8391
+ cookie?: never;
8392
+ };
8393
+ requestBody?: never;
8394
+ responses: {
8395
+ /** @description Every person in the org directory */
8396
+ 200: {
8397
+ headers: {
8398
+ [name: string]: unknown;
8399
+ };
8400
+ content: {
8401
+ "application/json": components["schemas"]["OrgDirectoryResponse"];
8402
+ };
8403
+ };
8404
+ };
8405
+ };
8299
8406
  listDomains: {
8300
8407
  parameters: {
8301
8408
  query?: never;
@@ -185,12 +185,14 @@ export const openApiRoutes = {
185
185
  '/api/workspace/auth-policy/test-sso': ['POST'],
186
186
  '/api/workspace/auth-policy/test-sso/{attemptId}': ['GET'],
187
187
  '/api/workspace/auth-policy/validate-oidc': ['POST'],
188
+ '/api/workspace/directory': ['GET'],
188
189
  '/api/workspace/domains': ['GET', 'POST'],
189
190
  '/api/workspace/domains/{id}': ['DELETE'],
190
191
  '/api/workspace/domains/{id}/verify': ['POST'],
191
192
  '/api/workspace/handle': ['PATCH'],
192
193
  '/api/workspace/invites': ['GET', 'POST'],
193
194
  '/api/workspace/invites/accept': ['POST'],
195
+ '/api/workspace/invites/batch': ['POST'],
194
196
  '/api/workspace/invites/validate': ['GET'],
195
197
  '/api/workspace/invites/{id}': ['DELETE'],
196
198
  '/api/workspace/members': ['GET'],
package/src/index.ts CHANGED
@@ -620,6 +620,31 @@ export type {
620
620
  PositionReporting,
621
621
  } from "./org/index";
622
622
 
623
+ // People directory: every `persons` row in the org, account-bearing or not.
624
+ // Neither the member list (RBAC principals) nor the org chart (topology) — the
625
+ // set an HRIS sync populates, and the only surface carrying a directory email.
626
+ // Capability-gated, deliberately: the org-chart read is authenticated-only.
627
+ export {
628
+ OrgDirectoryPersonSchema,
629
+ OrgDirectoryResponseSchema,
630
+ } from "./org/index";
631
+ export type { OrgDirectoryPerson, OrgDirectoryResponse } from "./org/index";
632
+
633
+ // Bulk invite, keyed by personId so recipients are resolved server-side from the
634
+ // caller's own directory rather than supplied by the caller.
635
+ export {
636
+ BATCH_INVITE_PERSONS_MAX,
637
+ BATCH_INVITE_SKIP_REASONS,
638
+ BatchInviteSkipReasonSchema,
639
+ BatchInviteRequestSchema,
640
+ BatchInviteResponseSchema,
641
+ } from "./org/index";
642
+ export type {
643
+ BatchInviteSkipReason,
644
+ BatchInviteRequest,
645
+ BatchInviteResponse,
646
+ } from "./org/index";
647
+
623
648
  // Source authority: per-field ownership — who may WRITE a given entity field.
624
649
  // Orthogonal to provenance (who reported). (ADR-CONT-083 / ADR-CTRL-182)
625
650
  export { SourceAuthoritySchema } from "./org/index";
@@ -54,6 +54,20 @@ export const HrConnectionStatusSchema = z
54
54
  "including when `credentialState` is 'unusable', so a dead " +
55
55
  "integration can still be disconnected. Null only when 'absent'.",
56
56
  }),
57
+ subdomain: z
58
+ .string()
59
+ .nullable()
60
+ .meta({
61
+ description:
62
+ "Customer tenant subdomain on the HR provider (e.g. the BambooHR " +
63
+ "company subdomain) that this connection points at. Present " +
64
+ "whenever a connection row exists, INCLUDING when " +
65
+ "`credentialState` is 'unusable', so a reconnect can prefill the " +
66
+ "tenant the org already synced instead of asking the user to " +
67
+ "retype it from memory — a one-character miss there is rejected " +
68
+ 'as "organization not found", which reads like the HR org ' +
69
+ "vanished rather than like a typo. Null only when 'absent'.",
70
+ }),
57
71
  employeeCount: z.number().int().nonnegative().nullable().meta({
58
72
  description:
59
73
  "Employees mirrored from the HR provider; null when never synced.",
package/src/org/README.md CHANGED
@@ -37,6 +37,14 @@ Shared type vocabulary for organization ownership, type classification, and tran
37
37
  - `AuthoritySource` _(type)_
38
38
  - `AuthoritySourceSchema` — Engine-internal authority source.
39
39
  - `AuthorizableView` _(type)_ — Type for views that can be checked against VIEWSCOPEMAP.
40
+ - `BATCH_INVITE_PERSONS_MAX` — Cap on one batch.
41
+ - `BATCH_INVITE_SKIP_REASONS` — Why one person in a batch did not receive an invite. - `already-member` — holds an account in this org…
42
+ - `BatchInviteRequest` _(type)_
43
+ - `BatchInviteRequestSchema`
44
+ - `BatchInviteResponse` _(type)_
45
+ - `BatchInviteResponseSchema`
46
+ - `BatchInviteSkipReason` _(type)_
47
+ - `BatchInviteSkipReasonSchema`
40
48
  - `COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES` — Cap on the MATERIALIZED document text, in UTF-8 bytes of the expanded result.
41
49
  - `COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS` — Cap on ONE presence position blob (`anchor` or `head`), in base64 chars.
42
50
  - `COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS` — Cap on ONE encoded update, measured on the base64 payload as it is sent.
@@ -178,6 +186,10 @@ Shared type vocabulary for organization ownership, type classification, and tran
178
186
  - `OrgBudgetConfigSchema`
179
187
  - `OrgDeletionStatus` _(type)_
180
188
  - `OrgDeletionStatusSchema`
189
+ - `OrgDirectoryPerson` _(type)_
190
+ - `OrgDirectoryPersonSchema`
191
+ - `OrgDirectoryResponse` _(type)_
192
+ - `OrgDirectoryResponseSchema`
181
193
  - `OrgDivergence` _(type)_
182
194
  - `OrgDivergenceResolution` _(type)_
183
195
  - `OrgDivergenceResolutionSchema` — What resolving a divergence means today.
package/src/org/index.ts CHANGED
@@ -354,6 +354,14 @@ export {
354
354
  CreateInviteRequestSchema,
355
355
  InviteResponseSchema,
356
356
  InviteListResponseSchema,
357
+ // People directory + bulk invite (personId-keyed, capability-gated)
358
+ OrgDirectoryPersonSchema,
359
+ OrgDirectoryResponseSchema,
360
+ BATCH_INVITE_PERSONS_MAX,
361
+ BATCH_INVITE_SKIP_REASONS,
362
+ BatchInviteSkipReasonSchema,
363
+ BatchInviteRequestSchema,
364
+ BatchInviteResponseSchema,
357
365
  DomainResponseSchema,
358
366
  DomainListResponseSchema,
359
367
  OrgBillingResponseSchema,
@@ -366,6 +374,11 @@ export type {
366
374
  CreateInviteRequestPayload,
367
375
  InviteResponse,
368
376
  InviteListResponse,
377
+ OrgDirectoryPerson,
378
+ OrgDirectoryResponse,
379
+ BatchInviteSkipReason,
380
+ BatchInviteRequest,
381
+ BatchInviteResponse,
369
382
  DomainResponse,
370
383
  DomainListResponse,
371
384
  OrgBillingResponse,
@@ -781,6 +781,116 @@ export type CreateInviteRequestPayload = z.infer<
781
781
  typeof CreateInviteRequestSchema
782
782
  >;
783
783
 
784
+ // ---------------------------------------------------------------------------
785
+ // GET /api/workspace/directory
786
+ //
787
+ // The org's PEOPLE DIRECTORY — every `persons` row, account-bearing or not.
788
+ // Distinct from the member list (`user_org_memberships`, RBAC principals) and
789
+ // from the org chart (`positions` + occupancy, topology): this is the answer to
790
+ // "who does this org know about", which after an HRIS sync is a much larger set
791
+ // than either.
792
+ //
793
+ // It exists because inviting a synced person needs their address, and the org
794
+ // chart cannot carry one: `GET /api/users/org-chart` is authenticated-only, so a
795
+ // `primaryEmail` there would let any member enumerate the whole company's email
796
+ // addresses. This surface is capability-gated instead.
797
+ // ---------------------------------------------------------------------------
798
+
799
+ export const OrgDirectoryPersonSchema = z.object({
800
+ personId: z.string().uuid(),
801
+ displayName: z.string(),
802
+ /**
803
+ * Nullable and NOT optional. A directory person with no address is a real,
804
+ * common state — an HRIS export with the email column unmapped produces a
805
+ * whole org of them — and it is the difference between "cannot be invited" and
806
+ * "not yet loaded". Callers must render the distinction rather than treat a
807
+ * missing address as absence of the person.
808
+ */
809
+ primaryEmail: z.string().nullable(),
810
+ /** The linked account, or null when this person has never signed up. */
811
+ userId: z.string().uuid().nullable(),
812
+ });
813
+
814
+ export type OrgDirectoryPerson = z.infer<typeof OrgDirectoryPersonSchema>;
815
+
816
+ export const OrgDirectoryResponseSchema = z.object({
817
+ people: z.array(OrgDirectoryPersonSchema),
818
+ });
819
+
820
+ export type OrgDirectoryResponse = z.infer<typeof OrgDirectoryResponseSchema>;
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // POST /api/workspace/invites/batch
824
+ //
825
+ // Bulk invite, keyed by `personId` rather than by email. That is a security
826
+ // property, not a convenience: the single-invite route takes a caller-supplied
827
+ // address and can therefore reach any mailbox on the internet, which is why it
828
+ // carries a strict per-minute limit. This route's recipients are resolved
829
+ // server-side from the caller's own `persons` rows, so the reachable set is
830
+ // bounded by the org's own directory and the arbitrary-recipient risk is gone.
831
+ //
832
+ // Consequence for callers: the client never needs to hold an email address to
833
+ // send an invite, and an unknown or cross-org `personId` is silently omitted
834
+ // from the response rather than reported — reporting it would make this a
835
+ // membership oracle.
836
+ // ---------------------------------------------------------------------------
837
+
838
+ /**
839
+ * Cap on one batch. Bounds worst-case request latency, since the invite emails
840
+ * are sent before the response returns (the raw token exists only in memory and
841
+ * must not be parked in a queue).
842
+ */
843
+ export const BATCH_INVITE_PERSONS_MAX = 100;
844
+
845
+ export const BatchInviteRequestSchema = z.object({
846
+ personIds: z.array(z.string().uuid()).min(1).max(BATCH_INVITE_PERSONS_MAX),
847
+ // Same restricted {admin, member} domain as a single invite.
848
+ role: z.enum(["admin", "member"]),
849
+ });
850
+
851
+ export type BatchInviteRequest = z.infer<typeof BatchInviteRequestSchema>;
852
+
853
+ /**
854
+ * Why one person in a batch did not receive an invite.
855
+ *
856
+ * - `already-member` — holds an account in this org already.
857
+ * - `invite-pending` — an unexpired invite is already outstanding.
858
+ * - `no-email` — the directory row carries no address to send to.
859
+ * - `send-failed` — the invite row COMMITTED but the email did not leave.
860
+ * Recoverable by resending; never a reason to roll the
861
+ * invite back.
862
+ */
863
+ export const BATCH_INVITE_SKIP_REASONS = [
864
+ "already-member",
865
+ "invite-pending",
866
+ "no-email",
867
+ "send-failed",
868
+ ] as const;
869
+
870
+ export const BatchInviteSkipReasonSchema = z.enum(BATCH_INVITE_SKIP_REASONS);
871
+
872
+ export type BatchInviteSkipReason = z.infer<typeof BatchInviteSkipReasonSchema>;
873
+
874
+ const BatchInviteOutcomeSchema = z.object({
875
+ personId: z.string().uuid(),
876
+ reason: BatchInviteSkipReasonSchema,
877
+ });
878
+
879
+ export const BatchInviteResponseSchema = z.object({
880
+ /** Person ids that now hold a pending invite AND were emailed. */
881
+ invited: z.array(z.string().uuid()),
882
+ /** Never attempted — see {@link BatchInviteSkipReason}. */
883
+ skipped: z.array(BatchInviteOutcomeSchema),
884
+ /**
885
+ * Invite row exists, delivery did not. Separate from `skipped` because the
886
+ * remedy differs: a skip needs the underlying state to change, a failure needs
887
+ * a resend.
888
+ */
889
+ failed: z.array(BatchInviteOutcomeSchema),
890
+ });
891
+
892
+ export type BatchInviteResponse = z.infer<typeof BatchInviteResponseSchema>;
893
+
784
894
  // ---------------------------------------------------------------------------
785
895
  // OrgDomain sub-schema
786
896
  // ---------------------------------------------------------------------------
@@ -20,6 +20,9 @@ export type ResourceKey =
20
20
  | { type: "teams"; orgId: string }
21
21
  | { type: "integrations"; orgId: string }
22
22
  | { type: "invites"; orgId: string }
23
+ // The people directory (`persons`) — every person the org knows about,
24
+ // account-bearing or not. Distinct from `members`, which is accounts only.
25
+ | { type: "orgDirectory"; orgId: string }
23
26
  | { type: "auditEvents"; orgId: string }
24
27
  | { type: "timeline"; orgId: string }
25
28
  // Identities (singular) — single entities
@@ -129,6 +129,7 @@ const ORG_SCOPED_TYPES = [
129
129
  "teams",
130
130
  "integrations",
131
131
  "invites",
132
+ "orgDirectory",
132
133
  "auditEvents",
133
134
  "timeline",
134
135
  "workspace",
@@ -266,6 +267,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
266
267
  case "teams":
267
268
  case "integrations":
268
269
  case "invites":
270
+ case "orgDirectory":
269
271
  case "auditEvents":
270
272
  case "timeline":
271
273
  case "workspace":