@company-semantics/contracts 46.0.0 → 47.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 +7 -1
- package/src/__tests__/resource-keys.test.ts +60 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +1 -0
- package/src/api/http/routes/ai-chat.ts +0 -1
- package/src/comments/README.md +3 -2
- package/src/comments/__tests__/schemas.test.ts +3 -2
- package/src/comments/schemas.ts +7 -6
- package/src/content/schemas.ts +23 -0
- package/src/identity/__tests__/avatar.test.ts +1 -1
- package/src/message-parts/__tests__/wire.test.ts +13 -4
- package/src/permissions/access-levels.ts +5 -1
- package/src/permissions/access-request.ts +4 -0
- package/src/resource-keys.ts +174 -107
- package/src/security/org-secrets.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "47.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"build": "tsc -b --noEmit",
|
|
87
87
|
"typecheck": "NODE_OPTIONS='--max-old-space-size=4096' tsc -b --noEmit",
|
|
88
88
|
"typecheck:ci": "NODE_OPTIONS='--max-old-space-size=4096' tsc -p scripts/ci/tsconfig.json",
|
|
89
|
+
"lint": "eslint src/ scripts/ tests/ __tests__/ vitest.config.ts",
|
|
89
90
|
"lint:md": "markdownlint-cli2 '**/*.md' '#node_modules' '#.ralph/worktrees' '#.claude/worktrees'",
|
|
90
91
|
"format": "prettier --write src/",
|
|
91
92
|
"format:check": "prettier --check src/",
|
|
@@ -137,9 +138,11 @@
|
|
|
137
138
|
"zod": "^4.4.3"
|
|
138
139
|
},
|
|
139
140
|
"devDependencies": {
|
|
141
|
+
"@eslint/js": "^10.0.1",
|
|
140
142
|
"@types/node": "^22.20.1",
|
|
141
143
|
"amphtml-validator": "^1.0.38",
|
|
142
144
|
"culori": "^4.0.2",
|
|
145
|
+
"eslint": "^10.8.1",
|
|
143
146
|
"husky": "^9.1.7",
|
|
144
147
|
"lint-staged": "^17.3.0",
|
|
145
148
|
"markdownlint-cli2": "^0.23.2",
|
|
@@ -147,6 +150,7 @@
|
|
|
147
150
|
"prettier": "^3.9.6",
|
|
148
151
|
"tsx": "^4.23.1",
|
|
149
152
|
"typescript": "^5.8.3",
|
|
153
|
+
"typescript-eslint": "^8.66.0",
|
|
150
154
|
"vite": "^8.2.0",
|
|
151
155
|
"vitest": "^4.1.10",
|
|
152
156
|
"yaml": "^2.9.0"
|
|
@@ -158,6 +162,8 @@
|
|
|
158
162
|
"nanoid@<3.3.17": ">=3.3.17",
|
|
159
163
|
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5.0.0",
|
|
160
164
|
"js-yaml@>=5.0.0 <5.2.2": ">=5.2.2 <6.0.0",
|
|
165
|
+
"ajv@<6.14.0": ">=6.14.0",
|
|
166
|
+
"flatted@<3.4.0": ">=3.4.0",
|
|
161
167
|
"picomatch@<4.0.4": ">=4.0.4",
|
|
162
168
|
"esbuild@<0.28.1": ">=0.28.1",
|
|
163
169
|
"linkify-it@<5.0.2": ">=5.0.2",
|
|
@@ -60,6 +60,65 @@ describe("resource-keys: orgUnitOpenRoles (per-unit identity)", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("resource-keys: directGrants (org-scoped)", () => {
|
|
64
|
+
const key: ResourceKey = { type: "directGrants", orgId: ORG_ID };
|
|
65
|
+
|
|
66
|
+
it("toQueryKey produces [type, orgId]", () => {
|
|
67
|
+
const qk = toQueryKey(key);
|
|
68
|
+
expect(qk).toEqual(["directGrants", ORG_ID]);
|
|
69
|
+
expect(qk).toHaveLength(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("roundtrips through fromQueryKey", () => {
|
|
73
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("matchesResourceKey is org-scoped: rejects a different orgId", () => {
|
|
77
|
+
const other = "22222222-2222-4222-8222-222222222222";
|
|
78
|
+
expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
|
|
79
|
+
expect(matchesResourceKey(["directGrants", other], key)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resource-keys: orgUnitMyAuthority (per-unit identity)", () => {
|
|
84
|
+
const UNIT_ID = "33333333-3333-4333-8333-333333333333";
|
|
85
|
+
const key: ResourceKey = {
|
|
86
|
+
type: "orgUnitMyAuthority",
|
|
87
|
+
orgId: ORG_ID,
|
|
88
|
+
unitId: UNIT_ID,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
it("toQueryKey produces [type, orgId, unitId]", () => {
|
|
92
|
+
const qk = toQueryKey(key);
|
|
93
|
+
expect(qk).toEqual(["orgUnitMyAuthority", ORG_ID, UNIT_ID]);
|
|
94
|
+
expect(qk).toHaveLength(3);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("roundtrips through fromQueryKey", () => {
|
|
98
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("matchesResourceKey is unitId-scoped: rejects a different unit", () => {
|
|
102
|
+
const otherUnit = "44444444-4444-4444-8444-444444444444";
|
|
103
|
+
expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
|
|
104
|
+
expect(
|
|
105
|
+
matchesResourceKey(["orgUnitMyAuthority", ORG_ID, otherUnit], key),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not collide with orgUnitPermissions on the same unit", () => {
|
|
110
|
+
const permissions: ResourceKey = {
|
|
111
|
+
type: "orgUnitPermissions",
|
|
112
|
+
orgId: ORG_ID,
|
|
113
|
+
unitId: UNIT_ID,
|
|
114
|
+
};
|
|
115
|
+
// Same subject, different resource: an invalidation of one must not sweep
|
|
116
|
+
// the other, or the split that motivated registering this key is inert.
|
|
117
|
+
expect(matchesResourceKey(toQueryKey(permissions), key)).toBe(false);
|
|
118
|
+
expect(matchesResourceKey(toQueryKey(key), permissions)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
63
122
|
describe("resource-keys: system-scoped internalAdmin* types", () => {
|
|
64
123
|
const systemTypes = [
|
|
65
124
|
"internalAdminAiProviders",
|
|
@@ -67,6 +126,7 @@ describe("resource-keys: system-scoped internalAdmin* types", () => {
|
|
|
67
126
|
"internalAdminAiRuntimeDefaults",
|
|
68
127
|
"factoryFloor",
|
|
69
128
|
"factorySnapshot",
|
|
129
|
+
"factoryKpis",
|
|
70
130
|
] as const;
|
|
71
131
|
|
|
72
132
|
for (const type of systemTypes) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '018976cde74a' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '018976cde74aa4a20c06c5fb333963bf58199fba93d623cd89cf2a360d95eceb' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -4840,6 +4840,7 @@ export interface components {
|
|
|
4840
4840
|
canEdit: boolean;
|
|
4841
4841
|
canUploadContext?: boolean;
|
|
4842
4842
|
canManageAcl?: boolean;
|
|
4843
|
+
canComment: boolean;
|
|
4843
4844
|
canChangeVisibility?: boolean;
|
|
4844
4845
|
inheritsFromId: string | null;
|
|
4845
4846
|
inheritsFrom: string | null;
|
package/src/comments/README.md
CHANGED
|
@@ -55,8 +55,9 @@ arbitrate — see the first invariant.
|
|
|
55
55
|
- **Mention identity renders from the mention row, never from the body
|
|
56
56
|
characters under the range.** A stale or forged range can only
|
|
57
57
|
mis-**highlight**; it can never misattribute a mention to the wrong person.
|
|
58
|
-
Offsets are nullable
|
|
59
|
-
|
|
58
|
+
Offsets are nullable — treat null as "highlight nothing", not as an error.
|
|
59
|
+
Mentions written since ADR-BE-522 carry a real range; older rows carry null
|
|
60
|
+
permanently, since recovering one would mean searching the body for a name.
|
|
60
61
|
- **These shapes are MIRRORED from the backend route boundary, not authored
|
|
61
62
|
here.** Every bound matches
|
|
62
63
|
`company-semantics-backend/src/api/http/routes/comments/comments.schemas.ts`,
|
|
@@ -147,8 +147,9 @@ describe("CommentSchema and the redaction invariant", () => {
|
|
|
147
147
|
|
|
148
148
|
describe("CommentMentionSchema offsets", () => {
|
|
149
149
|
it("parses a mention whose range is null", () => {
|
|
150
|
-
//
|
|
151
|
-
//
|
|
150
|
+
// The state of every mention written before ADR-BE-522, permanently: the
|
|
151
|
+
// ranges were never stored and cannot be recovered. A client must render
|
|
152
|
+
// these as "highlight nothing", so they must parse.
|
|
152
153
|
expect(
|
|
153
154
|
CommentMentionSchema.safeParse(
|
|
154
155
|
makeMention({ startOffset: null, endOffset: null }),
|
package/src/comments/schemas.ts
CHANGED
|
@@ -75,12 +75,13 @@ export type CommentSubjectType = z.infer<typeof CommentSubjectTypeSchema>;
|
|
|
75
75
|
* RANGE. A stale or forged range can therefore only mis-HIGHLIGHT; it can never
|
|
76
76
|
* misattribute a mention to the wrong person.
|
|
77
77
|
*
|
|
78
|
-
* `startOffset`/`endOffset` are NULLABLE, and null
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
78
|
+
* `startOffset`/`endOffset` are NULLABLE, and null means "this mention is in
|
|
79
|
+
* the body somewhere, highlight nothing" — NEVER an error. Every mention
|
|
80
|
+
* written since ADR-BE-522 carries a real range; rows written before it have
|
|
81
|
+
* none and never will, because the only way to invent one is to search the body
|
|
82
|
+
* for a display name, which is the body-as-identity inversion this contract
|
|
83
|
+
* exists to prevent. A client therefore has to render both, and the two coexist
|
|
84
|
+
* with no version check on either side.
|
|
84
85
|
*
|
|
85
86
|
* `displayName` is resolved AT READ TIME from the current user record and is
|
|
86
87
|
* never stored on the mention row: a rename re-renders with no backfill, and an
|
package/src/content/schemas.ts
CHANGED
|
@@ -173,6 +173,29 @@ export const CompanyMdDocResponseSchema = z.object({
|
|
|
173
173
|
* outside the single-doc read.
|
|
174
174
|
*/
|
|
175
175
|
canManageAcl: z.boolean().optional(),
|
|
176
|
+
/**
|
|
177
|
+
* CONVERSATION capability signal (ADR-CONTRACTS-118 / ADR-BE-520): whether the
|
|
178
|
+
* requesting actor holds `CompanyMd.CanComment` (the `commenter` band and up)
|
|
179
|
+
* on this doc — the SAME binding `assertCanCommentSubject` enforces on every
|
|
180
|
+
* comment write AND, since ADR-BE-520, `assertCanReadSubject` enforces on the
|
|
181
|
+
* thread read. The app mounts or withholds the ENTIRE comment surface on it —
|
|
182
|
+
* the rail, the toolbar control, the open-thread badge, the preview
|
|
183
|
+
* highlights, the gutter marks and the thread fetch itself — not merely a
|
|
184
|
+
* create affordance: a document's conversation sits inside that document's
|
|
185
|
+
* confidentiality envelope rather than forming an annotation layer over it
|
|
186
|
+
* (ADR-APP-122). Signal only; admittance and enforcement are unchanged.
|
|
187
|
+
*
|
|
188
|
+
* REQUIRED, unlike the three signals above it, and the departure is
|
|
189
|
+
* deliberate. Those are optional for back-compat with doc shapes returned
|
|
190
|
+
* outside the single-doc read; this one gates a confidentiality surface, where
|
|
191
|
+
* an absent value means "unknown" rather than "false" and any client-invented
|
|
192
|
+
* default is a guess. Making it required is what lets the app delete the
|
|
193
|
+
* `canComment ?? canEdit` shadow-field fallback outright instead of carrying a
|
|
194
|
+
* second, weaker answer beside the server's. Pre-launch there is no
|
|
195
|
+
* back-compat consumer to protect, so the field is required and the package
|
|
196
|
+
* takes the major bump.
|
|
197
|
+
*/
|
|
198
|
+
canComment: z.boolean(),
|
|
176
199
|
/**
|
|
177
200
|
* GOVERNANCE capability signal (ADR-BE-435): whether the requesting actor holds
|
|
178
201
|
* `CompanyMd.CanChangeVisibility` (owner OR the `org.manage_content_sharing`
|
|
@@ -50,7 +50,7 @@ describe("resolveAvatar", () => {
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
it("returns initials source when no avatarUrl", () => {
|
|
53
|
-
const result = resolveAvatar({ fullName: "Ian Heidt" }
|
|
53
|
+
const result = resolveAvatar({ fullName: "Ian Heidt" });
|
|
54
54
|
expect(result.source).toBe("initials");
|
|
55
55
|
expect(result.initials).toBe("IH");
|
|
56
56
|
expect(result.url).toBeUndefined();
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { WireSurfaceBuilder } from "../wire.js";
|
|
3
|
+
import type { MCPToolDescriptor } from "../../mcp/index.js";
|
|
3
4
|
import type { ExecutionResultData } from "../execution.js";
|
|
4
5
|
import type { UndoResultData } from "../execution.js";
|
|
5
6
|
|
|
7
|
+
// Deliberately partial fixtures: toolList is a pure wrapper that passes `tools`
|
|
8
|
+
// straight through, so these assert the wrapping, not descriptor validity. Cast
|
|
9
|
+
// through `unknown` rather than `any` — the narrowing is intentional and stated,
|
|
10
|
+
// not an absence of typing.
|
|
11
|
+
const partialTools = (
|
|
12
|
+
tools: ReadonlyArray<Record<string, unknown>>,
|
|
13
|
+
): MCPToolDescriptor[] => tools as unknown as MCPToolDescriptor[];
|
|
14
|
+
|
|
6
15
|
describe("WireSurfaceBuilder.toolList", () => {
|
|
7
16
|
it("returns data part with type data-tool-list", () => {
|
|
8
|
-
const tools = [
|
|
17
|
+
const tools = partialTools([
|
|
9
18
|
{ name: "search", description: "Search tool", inputSchema: {} },
|
|
10
|
-
]
|
|
19
|
+
]);
|
|
11
20
|
const result = WireSurfaceBuilder.toolList(tools);
|
|
12
21
|
expect(result.type).toBe("data-tool-list");
|
|
13
22
|
});
|
|
14
23
|
|
|
15
24
|
it("data.tools matches input array exactly", () => {
|
|
16
|
-
const tools = [
|
|
25
|
+
const tools = partialTools([
|
|
17
26
|
{ name: "search", description: "Search tool", inputSchema: {} },
|
|
18
27
|
{
|
|
19
28
|
name: "fetch",
|
|
20
29
|
description: "Fetch tool",
|
|
21
30
|
inputSchema: { type: "object" },
|
|
22
31
|
},
|
|
23
|
-
]
|
|
32
|
+
]);
|
|
24
33
|
const result = WireSurfaceBuilder.toolList(tools);
|
|
25
34
|
expect(result.data.tools).toEqual(tools);
|
|
26
35
|
});
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* levels are materialized into `effective_acl_grants`, and ADR-CTRL-086 for
|
|
10
10
|
* the aggregation rule (most-permissive across sources).
|
|
11
11
|
*
|
|
12
|
-
* The commenter level is
|
|
12
|
+
* The commenter level is NO LONGER a stub. ADR-CTRL-087 held the placeholder
|
|
13
|
+
* open; ADR-BE-512 discharged it by giving the band a real domain (polymorphic
|
|
14
|
+
* comment threads bound to `CompanyMd.CanComment`), and ADR-BE-520 made that
|
|
15
|
+
* binding gate thread reads as well as writes. See also ADR-CONTRACTS-118 for
|
|
16
|
+
* the `canComment` capability signal the doc read publishes.
|
|
13
17
|
*
|
|
14
18
|
* `meta` is the metadata-only tier used for org-admin lifecycle access
|
|
15
19
|
* (ADR-BE-245). It is materialized/derived only — never an explicit
|
|
@@ -46,6 +46,10 @@ export const ACCESS_REQUEST_MESSAGE_MAX_LENGTH = 280;
|
|
|
46
46
|
* is rendered as text, never as markup, so this is about what may be STORED,
|
|
47
47
|
* not about escaping at render time.
|
|
48
48
|
*/
|
|
49
|
+
// The control characters ARE the subject of this pattern — it exists to reject
|
|
50
|
+
// them. no-control-regex flags them as probable typos, which is the opposite of
|
|
51
|
+
// the intent documented above.
|
|
52
|
+
// eslint-disable-next-line no-control-regex
|
|
49
53
|
const PLAIN_TEXT = /^[^\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]*$/;
|
|
50
54
|
|
|
51
55
|
/** POST body to create an access request. */
|
package/src/resource-keys.ts
CHANGED
|
@@ -58,6 +58,9 @@ export type ResourceKey =
|
|
|
58
58
|
| { type: "deletionEligibility"; orgId: string }
|
|
59
59
|
| { type: "transferOwnership"; orgId: string }
|
|
60
60
|
| { type: "companyMdDocs"; orgId: string }
|
|
61
|
+
// Directly-granted (non-inherited) ACL rows for the org, read by the admin
|
|
62
|
+
// grants surface (ADR-CONTRACTS-119).
|
|
63
|
+
| { type: "directGrants"; orgId: string }
|
|
61
64
|
// OrgUnit canonical model (ADR-BE-120) — Phase 2 Wave 4
|
|
62
65
|
| { type: "orgTree"; orgId: string }
|
|
63
66
|
| { type: "orgLevelConfig"; orgId: string }
|
|
@@ -74,6 +77,12 @@ export type ResourceKey =
|
|
|
74
77
|
// Org-unit owners list (ADR-CONTRACTS-052) — owners are an org-wide
|
|
75
78
|
// projection, not a per-unit collection, so unitId is intentionally excluded.
|
|
76
79
|
| { type: "orgUnitOwners"; orgId: string }
|
|
80
|
+
// THIS VIEWER'S authority over one unit (ADR-CONTRACTS-119) — what the viewer
|
|
81
|
+
// may do here, not what the unit's permission rows say. Deliberately distinct
|
|
82
|
+
// from `orgUnitPermissions`: same subject, different resource and different
|
|
83
|
+
// invalidation trigger (a membership change alters the viewer's authority
|
|
84
|
+
// without touching the unit's rows), the `actionItems`/`feed` split above.
|
|
85
|
+
| { type: "orgUnitMyAuthority"; orgId: string; unitId: string }
|
|
77
86
|
// People reporting (ADR-BE-166) — drives the settings Org chart drill-down
|
|
78
87
|
| { type: "peopleOrgChart"; orgId: string }
|
|
79
88
|
// System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources.
|
|
@@ -85,6 +94,11 @@ export type ResourceKey =
|
|
|
85
94
|
// dashboard reads over Global-infra factory tables; tenant-less, scope 'system'.
|
|
86
95
|
| { type: "factoryFloor"; scope: "system" }
|
|
87
96
|
| { type: "factorySnapshot"; scope: "system" }
|
|
97
|
+
// The KPI rollup over the same factory tables (ADR-CONTRACTS-119). Registered
|
|
98
|
+
// late: the app shipped it as a double-cast key literal, and because
|
|
99
|
+
// `useResource` disables a query whose `toQueryKey` throws, the panel it backs
|
|
100
|
+
// never fetched at all. An unregistered key is not a stale read, it is no read.
|
|
101
|
+
| { type: "factoryKpis"; scope: "system" }
|
|
88
102
|
// Everything awaiting the viewer's decision, across every domain
|
|
89
103
|
// (ADR-CONT-104). ORG-scoped, not user-scoped, even though the content is
|
|
90
104
|
// per-viewer: the bucket differs per org, so a user-keyed entry would serve
|
|
@@ -128,7 +142,101 @@ export function resolveScope(context: {
|
|
|
128
142
|
return { orgId: context.impersonatedOrgId ?? context.orgId };
|
|
129
143
|
}
|
|
130
144
|
|
|
131
|
-
|
|
145
|
+
/*
|
|
146
|
+
* ---------------------------------------------------------------------------
|
|
147
|
+
* fromQueryKey's routing tables, and the drift detector over them.
|
|
148
|
+
* ---------------------------------------------------------------------------
|
|
149
|
+
*
|
|
150
|
+
* `toQueryKey` has always been safe: its switch is exhaustive and its `never`
|
|
151
|
+
* guard makes a new union member a compile error. `fromQueryKey` was not. Its
|
|
152
|
+
* five lookup tables are hand-maintained, and a member missing from all of them
|
|
153
|
+
* fell through to a RUNTIME `throw` — which `matchesResourceKey` catches and
|
|
154
|
+
* turns into "matches nothing", and which `useResource` turns into a query that
|
|
155
|
+
* never fetches. Silent in both directions (ADR-CONTRACTS-119).
|
|
156
|
+
*
|
|
157
|
+
* Two mechanisms, and BOTH are needed:
|
|
158
|
+
*
|
|
159
|
+
* `satisfies` on each table catches a WRONG entry — a typo, or a literal
|
|
160
|
+
* parked under the wrong scope. It cannot catch an entry that is simply
|
|
161
|
+
* absent, because an array is free to be a subset of its element type.
|
|
162
|
+
*
|
|
163
|
+
* `_EveryResourceKeyIsRouted` below catches the ABSENT entry, which is the
|
|
164
|
+
* failure that actually shipped. It is the half that closes the hole.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The identity segments a member carries — every field that is neither the type
|
|
169
|
+
* tag nor a scope discriminator. `member` → `"memberId"`; `commentThreads` →
|
|
170
|
+
* `"subjectType" | "subjectId"`; a scope-only key → `never`.
|
|
171
|
+
*/
|
|
172
|
+
type IdentityFieldsOf<T extends ResourceKey["type"]> = Exclude<
|
|
173
|
+
keyof Extract<ResourceKey, { type: T }>,
|
|
174
|
+
"type" | "orgId" | "userId" | "scope"
|
|
175
|
+
>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Type literals whose whole shape is the tag plus one scope discriminator —
|
|
179
|
+
* i.e. the members that belong in a scope ARRAY rather than an identity MAP.
|
|
180
|
+
* Distributes over the union (`K` is a naked type parameter).
|
|
181
|
+
*/
|
|
182
|
+
type ScopeOnlyType<K = ResourceKey> = K extends {
|
|
183
|
+
type: infer T extends ResourceKey["type"];
|
|
184
|
+
}
|
|
185
|
+
? [IdentityFieldsOf<T>] extends [never]
|
|
186
|
+
? T
|
|
187
|
+
: never
|
|
188
|
+
: never;
|
|
189
|
+
|
|
190
|
+
type OrgScopedType = Extract<
|
|
191
|
+
ScopeOnlyType,
|
|
192
|
+
Extract<ResourceKey, { orgId: string }>["type"]
|
|
193
|
+
>;
|
|
194
|
+
type UserScopedType = Extract<
|
|
195
|
+
ScopeOnlyType,
|
|
196
|
+
Extract<ResourceKey, { userId: string }>["type"]
|
|
197
|
+
>;
|
|
198
|
+
type SystemScopedType = Extract<
|
|
199
|
+
ScopeOnlyType,
|
|
200
|
+
Extract<ResourceKey, { scope: "system" }>["type"]
|
|
201
|
+
>;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Identities with a single extra segment. The `satisfies` checks the FIELD NAME
|
|
205
|
+
* against that member's own keys, so renaming `memberId` in the union — or
|
|
206
|
+
* pointing a key at a field it does not have — fails to compile here.
|
|
207
|
+
*/
|
|
208
|
+
const IDENTITY_FIELDS = {
|
|
209
|
+
member: "memberId",
|
|
210
|
+
team: "teamId",
|
|
211
|
+
department: "departmentId",
|
|
212
|
+
chat: "chatId",
|
|
213
|
+
companyMdDoc: "slug",
|
|
214
|
+
companyMdContextBank: "slug",
|
|
215
|
+
companyMdAccessRequests: "docId",
|
|
216
|
+
orgUnit: "unitId",
|
|
217
|
+
orgUnitChildren: "unitId",
|
|
218
|
+
orgUnitAncestors: "unitId",
|
|
219
|
+
orgUnitMemberships: "unitId",
|
|
220
|
+
orgUnitPermissions: "unitId",
|
|
221
|
+
orgUnitOpenRoles: "unitId",
|
|
222
|
+
orgUnitMyAuthority: "unitId",
|
|
223
|
+
} as const satisfies { [T in ResourceKey["type"]]?: IdentityFieldsOf<T> };
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Identities with a COMPOSITE (multi-segment) identity. Checked before
|
|
227
|
+
* {@link IDENTITY_FIELDS}, which hard-asserts a two-element `rest` and would
|
|
228
|
+
* otherwise reject these.
|
|
229
|
+
*
|
|
230
|
+
* A map of its own rather than widening `IDENTITY_FIELDS` to `string | string[]`:
|
|
231
|
+
* the single-segment case is every other key in the union, and making it pay for
|
|
232
|
+
* this one would put a branch in the hot path for no reader's benefit.
|
|
233
|
+
*/
|
|
234
|
+
const COMPOSITE_IDENTITY_FIELDS = {
|
|
235
|
+
commentThreads: ["subjectType", "subjectId"],
|
|
236
|
+
} as const satisfies {
|
|
237
|
+
[T in ResourceKey["type"]]?: readonly IdentityFieldsOf<T>[];
|
|
238
|
+
};
|
|
239
|
+
|
|
132
240
|
const ORG_SCOPED_TYPES = [
|
|
133
241
|
"members",
|
|
134
242
|
"departments",
|
|
@@ -146,13 +254,14 @@ const ORG_SCOPED_TYPES = [
|
|
|
146
254
|
"deletionEligibility",
|
|
147
255
|
"transferOwnership",
|
|
148
256
|
"companyMdDocs",
|
|
257
|
+
"directGrants",
|
|
149
258
|
"orgTree",
|
|
150
259
|
"orgLevelConfig",
|
|
151
260
|
"peopleOrgChart",
|
|
152
261
|
"orgUnitOwners",
|
|
153
262
|
"actionItems",
|
|
154
263
|
"feed",
|
|
155
|
-
] as const;
|
|
264
|
+
] as const satisfies readonly OrgScopedType[];
|
|
156
265
|
|
|
157
266
|
const USER_SCOPED_TYPES = [
|
|
158
267
|
"dismissedBanners",
|
|
@@ -160,7 +269,7 @@ const USER_SCOPED_TYPES = [
|
|
|
160
269
|
"sessions",
|
|
161
270
|
"userMd",
|
|
162
271
|
"viewer",
|
|
163
|
-
] as const;
|
|
272
|
+
] as const satisfies readonly UserScopedType[];
|
|
164
273
|
|
|
165
274
|
/**
|
|
166
275
|
* System-scoped types (ADR-CONTRACTS-052) — tenant-less super-admin resources.
|
|
@@ -173,7 +282,34 @@ const SYSTEM_SCOPED_TYPES = [
|
|
|
173
282
|
"internalAdminAiRuntimeDefaults",
|
|
174
283
|
"factoryFloor",
|
|
175
284
|
"factorySnapshot",
|
|
176
|
-
|
|
285
|
+
"factoryKpis",
|
|
286
|
+
] as const satisfies readonly SystemScopedType[];
|
|
287
|
+
|
|
288
|
+
/** Every literal `fromQueryKey` can route, across all five tables. */
|
|
289
|
+
type RoutedType =
|
|
290
|
+
| keyof typeof IDENTITY_FIELDS
|
|
291
|
+
| keyof typeof COMPOSITE_IDENTITY_FIELDS
|
|
292
|
+
| (typeof ORG_SCOPED_TYPES)[number]
|
|
293
|
+
| (typeof USER_SCOPED_TYPES)[number]
|
|
294
|
+
| (typeof SYSTEM_SCOPED_TYPES)[number];
|
|
295
|
+
|
|
296
|
+
type Assert<T extends true> = T;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* THE DRIFT DETECTOR. Register a union member and forget its routing table and
|
|
300
|
+
* this fails to compile — instead of shipping a key that parses nowhere,
|
|
301
|
+
* matches nothing, and disables the query that reads it.
|
|
302
|
+
*
|
|
303
|
+
* The false branch resolves to the UNROUTED LITERALS rather than to `false`, so
|
|
304
|
+
* the diagnostic names the culprit:
|
|
305
|
+
* `Type '"directGrants"' does not satisfy the constraint 'true'`.
|
|
306
|
+
* Deliberately unexported: this is an assertion, not vocabulary.
|
|
307
|
+
*/
|
|
308
|
+
type _EveryResourceKeyIsRouted = Assert<
|
|
309
|
+
[Exclude<ResourceKey["type"], RoutedType>] extends [never]
|
|
310
|
+
? true
|
|
311
|
+
: Exclude<ResourceKey["type"], RoutedType>
|
|
312
|
+
>;
|
|
177
313
|
|
|
178
314
|
/**
|
|
179
315
|
* Canonical ResourceKey → query key conversion.
|
|
@@ -214,6 +350,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
214
350
|
case "orgUnitMemberships":
|
|
215
351
|
case "orgUnitPermissions":
|
|
216
352
|
case "orgUnitOpenRoles":
|
|
353
|
+
case "orgUnitMyAuthority":
|
|
217
354
|
return [key.type, key.orgId, key.unitId] as const;
|
|
218
355
|
|
|
219
356
|
// User-scoped
|
|
@@ -241,6 +378,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
241
378
|
case "deletionEligibility":
|
|
242
379
|
case "transferOwnership":
|
|
243
380
|
case "companyMdDocs":
|
|
381
|
+
case "directGrants":
|
|
244
382
|
case "orgTree":
|
|
245
383
|
case "orgLevelConfig":
|
|
246
384
|
case "peopleOrgChart":
|
|
@@ -255,6 +393,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
255
393
|
case "internalAdminAiRuntimeDefaults":
|
|
256
394
|
case "factoryFloor":
|
|
257
395
|
case "factorySnapshot":
|
|
396
|
+
case "factoryKpis":
|
|
258
397
|
return [key.type, key.scope] as const;
|
|
259
398
|
|
|
260
399
|
default: {
|
|
@@ -277,19 +416,9 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
277
416
|
);
|
|
278
417
|
}
|
|
279
418
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
// A map of its own rather than a widening of `identityFields` to a
|
|
285
|
-
// `string | string[]`: the single-segment case is every other key in the
|
|
286
|
-
// union, and making it pay for this one would put a branch in the hot path
|
|
287
|
-
// for no reader's benefit.
|
|
288
|
-
const compositeIdentityFields: Record<string, readonly string[]> = {
|
|
289
|
-
commentThreads: ["subjectType", "subjectId"],
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const compositeFields = compositeIdentityFields[type];
|
|
419
|
+
const compositeFields = (
|
|
420
|
+
COMPOSITE_IDENTITY_FIELDS as Record<string, readonly string[] | undefined>
|
|
421
|
+
)[type];
|
|
293
422
|
if (compositeFields) {
|
|
294
423
|
// +1 for the orgId that leads every composite key.
|
|
295
424
|
if (rest.length !== compositeFields.length + 1) {
|
|
@@ -304,33 +433,21 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
304
433
|
return key as ResourceKey;
|
|
305
434
|
}
|
|
306
435
|
|
|
307
|
-
// Identities with extra
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
companyMdDoc: "slug",
|
|
314
|
-
companyMdContextBank: "slug",
|
|
315
|
-
companyMdAccessRequests: "docId",
|
|
316
|
-
orgUnit: "unitId",
|
|
317
|
-
orgUnitChildren: "unitId",
|
|
318
|
-
orgUnitAncestors: "unitId",
|
|
319
|
-
orgUnitMemberships: "unitId",
|
|
320
|
-
orgUnitPermissions: "unitId",
|
|
321
|
-
orgUnitOpenRoles: "unitId",
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
if (type in identityFields) {
|
|
436
|
+
// Identities with a single extra segment.
|
|
437
|
+
const identityField = (IDENTITY_FIELDS as Record<string, string | undefined>)[
|
|
438
|
+
type
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
if (identityField !== undefined) {
|
|
325
442
|
if (rest.length !== 2) {
|
|
326
443
|
throw new Error(
|
|
327
|
-
`Invalid query key for '${type}': expected [type, orgId, ${
|
|
444
|
+
`Invalid query key for '${type}': expected [type, orgId, ${identityField}], got ${JSON.stringify(queryKey)}`,
|
|
328
445
|
);
|
|
329
446
|
}
|
|
330
447
|
return {
|
|
331
448
|
type,
|
|
332
449
|
orgId: rest[0],
|
|
333
|
-
[
|
|
450
|
+
[identityField]: rest[1],
|
|
334
451
|
} as ResourceKey;
|
|
335
452
|
}
|
|
336
453
|
|
|
@@ -374,76 +491,26 @@ export function matchesResourceKey(
|
|
|
374
491
|
|
|
375
492
|
if (parsed.type !== targetKey.type) return false;
|
|
376
493
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
"teamId" in targetKey &&
|
|
398
|
-
parsed.teamId !== targetKey.teamId
|
|
399
|
-
)
|
|
400
|
-
return false;
|
|
401
|
-
if (
|
|
402
|
-
"departmentId" in parsed &&
|
|
403
|
-
"departmentId" in targetKey &&
|
|
404
|
-
parsed.departmentId !== targetKey.departmentId
|
|
405
|
-
)
|
|
406
|
-
return false;
|
|
407
|
-
if (
|
|
408
|
-
"chatId" in parsed &&
|
|
409
|
-
"chatId" in targetKey &&
|
|
410
|
-
parsed.chatId !== targetKey.chatId
|
|
411
|
-
)
|
|
412
|
-
return false;
|
|
413
|
-
if ("slug" in parsed && "slug" in targetKey && parsed.slug !== targetKey.slug)
|
|
414
|
-
return false;
|
|
415
|
-
if (
|
|
416
|
-
"docId" in parsed &&
|
|
417
|
-
"docId" in targetKey &&
|
|
418
|
-
parsed.docId !== targetKey.docId
|
|
419
|
-
)
|
|
420
|
-
return false;
|
|
421
|
-
if (
|
|
422
|
-
"unitId" in parsed &&
|
|
423
|
-
"unitId" in targetKey &&
|
|
424
|
-
parsed.unitId !== targetKey.unitId
|
|
425
|
-
)
|
|
426
|
-
return false;
|
|
427
|
-
// Both halves of a composite identity, or a comment thread list on one
|
|
428
|
-
// subject would be invalidated by every other subject in the org.
|
|
429
|
-
if (
|
|
430
|
-
"subjectType" in parsed &&
|
|
431
|
-
"subjectType" in targetKey &&
|
|
432
|
-
parsed.subjectType !== targetKey.subjectType
|
|
433
|
-
)
|
|
434
|
-
return false;
|
|
435
|
-
if (
|
|
436
|
-
"subjectId" in parsed &&
|
|
437
|
-
"subjectId" in targetKey &&
|
|
438
|
-
parsed.subjectId !== targetKey.subjectId
|
|
439
|
-
)
|
|
440
|
-
return false;
|
|
441
|
-
if (
|
|
442
|
-
"scope" in parsed &&
|
|
443
|
-
"scope" in targetKey &&
|
|
444
|
-
parsed.scope !== targetKey.scope
|
|
445
|
-
)
|
|
446
|
-
return false;
|
|
494
|
+
// Field-agnostic on purpose. This was an eleven-branch ladder naming every
|
|
495
|
+
// scope and identity field by hand — a second, silent mirror of the union's
|
|
496
|
+
// shape. A member whose identity segment the ladder forgot still matched, so
|
|
497
|
+
// one subject's invalidation flushed every other subject's cache; that is the
|
|
498
|
+
// ADR-CONTRACTS-116 `subjectType` defect, and enumerating fields is what made
|
|
499
|
+
// it possible. Comparing whatever the target actually carries covers a new
|
|
500
|
+
// identity field by construction (ADR-CONTRACTS-119).
|
|
501
|
+
//
|
|
502
|
+
// Same-type comparison only — `parsed.type === targetKey.type` above — so the
|
|
503
|
+
// two sides are the same union member and a shared field name means the same
|
|
504
|
+
// thing on both. A field present on one side and absent on the other still
|
|
505
|
+
// matches, which is what lets a slug-less `{type:'companyMdDocs', orgId}`
|
|
506
|
+
// sweep its whole type org-wide, exactly as before.
|
|
507
|
+
const target = targetKey as Record<string, unknown>;
|
|
508
|
+
const candidate = parsed as Record<string, unknown>;
|
|
509
|
+
for (const field of Object.keys(target)) {
|
|
510
|
+
if (field === "type") continue;
|
|
511
|
+
const other = candidate[field];
|
|
512
|
+
if (other !== undefined && other !== target[field]) return false;
|
|
513
|
+
}
|
|
447
514
|
|
|
448
515
|
return true;
|
|
449
516
|
}
|
|
@@ -41,6 +41,9 @@ export const OrgSecretsActionSchema = z.enum([
|
|
|
41
41
|
]);
|
|
42
42
|
export type OrgSecretsAction = z.infer<typeof OrgSecretsActionSchema>;
|
|
43
43
|
|
|
44
|
+
// The brand exists only to be referenced in a type position below; the value
|
|
45
|
+
// binding is never read, which is the whole point of the nominal-typing idiom.
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
44
47
|
declare const SecretValueStringBrand: unique symbol;
|
|
45
48
|
|
|
46
49
|
/**
|