@company-semantics/contracts 42.0.0 → 43.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.
- package/package.json +1 -1
- package/src/__tests__/resource-keys.test.ts +44 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +6 -6
- package/src/index.ts +31 -0
- package/src/resource-keys.ts +49 -0
- package/src/security/token-policy.ts +2 -16
- package/src/user-events/README.md +66 -0
- package/src/user-events/__tests__/README.md +43 -0
- package/src/user-events/__tests__/user-events.test.ts +196 -0
- package/src/user-events/index.ts +25 -0
- package/src/user-events/schemas.ts +173 -0
package/package.json
CHANGED
|
@@ -97,3 +97,47 @@ describe("resource-keys: system-scoped internalAdmin* types", () => {
|
|
|
97
97
|
).toBe(false);
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("resource-keys: companyMdAccessRequests (per-doc identity)", () => {
|
|
102
|
+
const DOC_ID = "22222222-2222-4222-8222-222222222222";
|
|
103
|
+
const key: ResourceKey = {
|
|
104
|
+
type: "companyMdAccessRequests",
|
|
105
|
+
orgId: ORG_ID,
|
|
106
|
+
docId: DOC_ID,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
it("serialises to [type, orgId, docId]", () => {
|
|
110
|
+
expect(toQueryKey(key)).toEqual([
|
|
111
|
+
"companyMdAccessRequests",
|
|
112
|
+
ORG_ID,
|
|
113
|
+
DOC_ID,
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("round-trips through fromQueryKey", () => {
|
|
118
|
+
expect(fromQueryKey(toQueryKey(key))).toEqual(key);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("discriminates two keys differing only in docId", () => {
|
|
122
|
+
const other: ResourceKey = {
|
|
123
|
+
type: "companyMdAccessRequests",
|
|
124
|
+
orgId: ORG_ID,
|
|
125
|
+
docId: "33333333-3333-4333-8333-333333333333",
|
|
126
|
+
};
|
|
127
|
+
expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
|
|
128
|
+
expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does NOT collide with companyMdDoc, whose id field is named `slug`", () => {
|
|
132
|
+
// companyMdDoc carries the stable doc id under a field named `slug`
|
|
133
|
+
// (ADR-BE-315 made slugs only parent-scoped unique). The two keys must stay
|
|
134
|
+
// distinguishable even when they carry the same document id.
|
|
135
|
+
const docKey: ResourceKey = {
|
|
136
|
+
type: "companyMdDoc",
|
|
137
|
+
orgId: ORG_ID,
|
|
138
|
+
slug: DOC_ID,
|
|
139
|
+
};
|
|
140
|
+
expect(matchesResourceKey(toQueryKey(docKey), key)).toBe(false);
|
|
141
|
+
expect(matchesResourceKey(toQueryKey(key), docKey)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -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 = '017f7c7f6a72' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '017f7c7f6a72c27413c0329be3eb9ff39547d425f575d570970d6e6920324638' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -3529,10 +3529,10 @@ export interface components {
|
|
|
3529
3529
|
share: {
|
|
3530
3530
|
id: string;
|
|
3531
3531
|
chatId: string;
|
|
3532
|
-
token
|
|
3532
|
+
token?: string;
|
|
3533
3533
|
/** @enum {string} */
|
|
3534
3534
|
visibility: "private" | "public";
|
|
3535
|
-
shareUrl
|
|
3535
|
+
shareUrl?: string;
|
|
3536
3536
|
messageCountAtShare: number;
|
|
3537
3537
|
titleAtShare: string;
|
|
3538
3538
|
/** Format: date-time */
|
|
@@ -3552,10 +3552,10 @@ export interface components {
|
|
|
3552
3552
|
shares: {
|
|
3553
3553
|
id: string;
|
|
3554
3554
|
chatId: string;
|
|
3555
|
-
token
|
|
3555
|
+
token?: string;
|
|
3556
3556
|
/** @enum {string} */
|
|
3557
3557
|
visibility: "private" | "public";
|
|
3558
|
-
shareUrl
|
|
3558
|
+
shareUrl?: string;
|
|
3559
3559
|
messageCountAtShare: number;
|
|
3560
3560
|
titleAtShare: string;
|
|
3561
3561
|
/** Format: date-time */
|
|
@@ -3568,10 +3568,10 @@ export interface components {
|
|
|
3568
3568
|
share: {
|
|
3569
3569
|
id: string;
|
|
3570
3570
|
chatId: string;
|
|
3571
|
-
token
|
|
3571
|
+
token?: string;
|
|
3572
3572
|
/** @enum {string} */
|
|
3573
3573
|
visibility: "private" | "public";
|
|
3574
|
-
shareUrl
|
|
3574
|
+
shareUrl?: string;
|
|
3575
3575
|
messageCountAtShare: number;
|
|
3576
3576
|
titleAtShare: string;
|
|
3577
3577
|
/** Format: date-time */
|
package/src/index.ts
CHANGED
|
@@ -266,6 +266,36 @@ export type {
|
|
|
266
266
|
ActionItemTargetType,
|
|
267
267
|
} from "./action-items/index";
|
|
268
268
|
|
|
269
|
+
// User-scoped push envelope — the frames any domain may put on ONE
|
|
270
|
+
// authenticated user's connection without owning a route or an SSE union of its
|
|
271
|
+
// own. A THIRD vocabulary alongside the two above, and deliberately unlike
|
|
272
|
+
// both: a notification LEAVES the building, an action item is STANDING state,
|
|
273
|
+
// and a user event is a single unreplayable instant meaning "your cache is
|
|
274
|
+
// stale". This stream is a wake-up channel, NOT an ordered event log — no frame
|
|
275
|
+
// carries an SSE `id:` and nothing durable may be hung off it.
|
|
276
|
+
// See src/user-events/README.md.
|
|
277
|
+
export {
|
|
278
|
+
MAX_INVALIDATION_KEYS,
|
|
279
|
+
USER_EVENT_RESYNC_REASONS,
|
|
280
|
+
} from "./user-events/index";
|
|
281
|
+
|
|
282
|
+
export {
|
|
283
|
+
ResourceInvalidatedEventSchema,
|
|
284
|
+
UserEventBaseSchema,
|
|
285
|
+
UserEventConnectedSchema,
|
|
286
|
+
UserEventResyncReasonSchema,
|
|
287
|
+
UserEventResyncSchema,
|
|
288
|
+
UserSseEventSchema,
|
|
289
|
+
} from "./user-events/index";
|
|
290
|
+
|
|
291
|
+
export type {
|
|
292
|
+
ResourceInvalidatedEvent,
|
|
293
|
+
UserEventConnected,
|
|
294
|
+
UserEventResync,
|
|
295
|
+
UserEventResyncReason,
|
|
296
|
+
UserSseEvent,
|
|
297
|
+
} from "./user-events/index";
|
|
298
|
+
|
|
269
299
|
// Chat domain types
|
|
270
300
|
// @see PRD-00142 for share chat design rationale
|
|
271
301
|
export type {
|
|
@@ -859,6 +889,7 @@ export {
|
|
|
859
889
|
toQueryKey,
|
|
860
890
|
fromQueryKey,
|
|
861
891
|
matchesResourceKey,
|
|
892
|
+
isResourceKeyShape,
|
|
862
893
|
} from "./resource-keys";
|
|
863
894
|
|
|
864
895
|
// Resource response wrapper (typed versioning for cache invalidation)
|
package/src/resource-keys.ts
CHANGED
|
@@ -20,6 +20,15 @@ export type ResourceKey =
|
|
|
20
20
|
| { type: "chat"; orgId: string; chatId: string }
|
|
21
21
|
| { type: "companyMdDoc"; orgId: string; slug: string }
|
|
22
22
|
| { type: "companyMdContextBank"; orgId: string; slug: string }
|
|
23
|
+
// The access requests filed against ONE document — the owner's inbox, read by
|
|
24
|
+
// the Share dialog.
|
|
25
|
+
//
|
|
26
|
+
// Discriminated by `docId`, NOT `slug`, and the difference is load-bearing.
|
|
27
|
+
// `companyMdDoc` above carries the stable doc id under a field named `slug`
|
|
28
|
+
// for historical reasons; ADR-BE-315 made slugs only PARENT-SCOPED unique, so
|
|
29
|
+
// a bare slug does not identify a document. That field name is a wart to be
|
|
30
|
+
// contained, not propagated — a new key gets the honest name.
|
|
31
|
+
| { type: "companyMdAccessRequests"; orgId: string; docId: string }
|
|
23
32
|
| { type: "workspace"; orgId: string }
|
|
24
33
|
| { type: "workspaceDomains"; orgId: string }
|
|
25
34
|
| { type: "authSettings"; orgId: string }
|
|
@@ -151,6 +160,8 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
|
|
|
151
160
|
case "companyMdDoc":
|
|
152
161
|
case "companyMdContextBank":
|
|
153
162
|
return [key.type, key.orgId, key.slug] as const;
|
|
163
|
+
case "companyMdAccessRequests":
|
|
164
|
+
return [key.type, key.orgId, key.docId] as const;
|
|
154
165
|
|
|
155
166
|
// OrgUnit identity keys (ADR-BE-120)
|
|
156
167
|
case "orgUnit":
|
|
@@ -228,6 +239,7 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
|
|
|
228
239
|
chat: "chatId",
|
|
229
240
|
companyMdDoc: "slug",
|
|
230
241
|
companyMdContextBank: "slug",
|
|
242
|
+
companyMdAccessRequests: "docId",
|
|
231
243
|
orgUnit: "unitId",
|
|
232
244
|
orgUnitChildren: "unitId",
|
|
233
245
|
orgUnitAncestors: "unitId",
|
|
@@ -327,6 +339,12 @@ export function matchesResourceKey(
|
|
|
327
339
|
return false;
|
|
328
340
|
if ("slug" in parsed && "slug" in targetKey && parsed.slug !== targetKey.slug)
|
|
329
341
|
return false;
|
|
342
|
+
if (
|
|
343
|
+
"docId" in parsed &&
|
|
344
|
+
"docId" in targetKey &&
|
|
345
|
+
parsed.docId !== targetKey.docId
|
|
346
|
+
)
|
|
347
|
+
return false;
|
|
330
348
|
if (
|
|
331
349
|
"unitId" in parsed &&
|
|
332
350
|
"unitId" in targetKey &&
|
|
@@ -343,6 +361,37 @@ export function matchesResourceKey(
|
|
|
343
361
|
return true;
|
|
344
362
|
}
|
|
345
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Is this the SHAPE of a ResourceKey?
|
|
366
|
+
*
|
|
367
|
+
* A wire-boundary predicate, deliberately NOT a vocabulary check: it asserts a
|
|
368
|
+
* non-empty `type` and exactly one scope discriminator, and says nothing about
|
|
369
|
+
* whether `type` is a member of the union.
|
|
370
|
+
*
|
|
371
|
+
* The asymmetry is the point. `ResourceKey` is a large union whose authority is
|
|
372
|
+
* `toQueryKey`'s exhaustive switch; a Zod mirror of it would be a second source
|
|
373
|
+
* of truth that drifts. Worse, a vocabulary-strict gate would REJECT AN ENTIRE
|
|
374
|
+
* FRAME when a newer backend names a key this client's contracts version has
|
|
375
|
+
* not learned yet — today an unknown key simply matches no query and the rest
|
|
376
|
+
* of the frame still lands, which is the behaviour worth keeping.
|
|
377
|
+
* `matchesResourceKey` is already lenient about unknown types at runtime, so a
|
|
378
|
+
* stricter gate here would be the only place in the pipeline that hard-fails on
|
|
379
|
+
* a key it merely does not recognise.
|
|
380
|
+
*
|
|
381
|
+
* Used by the user-event wire schema (`../user-events`) to validate
|
|
382
|
+
* `resource.invalidated` payloads without importing the union's vocabulary.
|
|
383
|
+
*/
|
|
384
|
+
export function isResourceKeyShape(value: unknown): value is ResourceKey {
|
|
385
|
+
if (typeof value !== "object" || value === null) return false;
|
|
386
|
+
const candidate = value as Record<string, unknown>;
|
|
387
|
+
if (typeof candidate.type !== "string" || candidate.type.length === 0)
|
|
388
|
+
return false;
|
|
389
|
+
const scopeCount = ["orgId", "userId", "scope"].filter(
|
|
390
|
+
(field) => typeof candidate[field] === "string",
|
|
391
|
+
).length;
|
|
392
|
+
return scopeCount === 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
346
395
|
function isReadonlyArray(value: unknown): value is readonly unknown[] {
|
|
347
396
|
return Array.isArray(value);
|
|
348
397
|
}
|
|
@@ -61,28 +61,14 @@ export const AUTH_TOKEN_POLICY_REQUIREMENTS = {
|
|
|
61
61
|
hashAlgorithm: "SHA-256",
|
|
62
62
|
storageFormat: "hashed",
|
|
63
63
|
contextPatterns: ["session"],
|
|
64
|
-
conformance: {
|
|
65
|
-
status: "unmet",
|
|
66
|
-
gap:
|
|
67
|
-
"Issuance now conforms: minted by issueCapabilityToken at 256 bits and " +
|
|
68
|
-
"stored as a sha256:v1: digest (ADR-BE-460 deploy B). Still UNMET because " +
|
|
69
|
-
"the dual-read window is open — sessions.token retains the raw value so a " +
|
|
70
|
-
"rollback keeps working, and every lookup carries a plaintext fallback arm. " +
|
|
71
|
-
"Conformance is reached when migration C retires the column and deploy D " +
|
|
72
|
-
"removes the fallbacks.",
|
|
73
|
-
},
|
|
64
|
+
conformance: { status: "enforced" },
|
|
74
65
|
},
|
|
75
66
|
ChatShareToken: {
|
|
76
67
|
minEntropyBits: 256,
|
|
77
68
|
hashAlgorithm: "SHA-256",
|
|
78
69
|
storageFormat: "hashed",
|
|
79
70
|
contextPatterns: ["share"],
|
|
80
|
-
conformance: {
|
|
81
|
-
status: "unmet",
|
|
82
|
-
gap:
|
|
83
|
-
"Minted with randomBytes(32) and persisted verbatim: chat_shares.token holds " +
|
|
84
|
-
"the share-URL value itself, and lookup is raw equality against it.",
|
|
85
|
-
},
|
|
71
|
+
conformance: { status: "enforced" },
|
|
86
72
|
},
|
|
87
73
|
InviteToken: {
|
|
88
74
|
minEntropyBits: 256,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# user-events/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The published wire contract for the **user-scoped push stream** — the frames any
|
|
6
|
+
domain may send to one authenticated user without owning a route or an SSE union
|
|
7
|
+
of its own.
|
|
8
|
+
|
|
9
|
+
## Invariants
|
|
10
|
+
|
|
11
|
+
- **This stream is a wake-up channel plus ephemeral cache hints. It is NOT an
|
|
12
|
+
ordered event log.** Publishing a user event grants no replay guarantee, and
|
|
13
|
+
no durable effect may be hung off it.
|
|
14
|
+
- **No frame carries an SSE `id:` line.** An id implies a resumable position;
|
|
15
|
+
there is no log behind this stream, so an id would be a lie. Contrast
|
|
16
|
+
`ExecutionSseEvent`, whose `eventSequence` legitimately doubles as
|
|
17
|
+
`Last-Event-ID` because it projects a durable row (ADR-CONT-066).
|
|
18
|
+
- **Delivery is edge-triggered only.** A missed frame may leave caches stale
|
|
19
|
+
until the next authoritative refresh. Acceptable because no correctness
|
|
20
|
+
depends on invalidations — every frame means "re-read this", and the read is
|
|
21
|
+
the authority.
|
|
22
|
+
- **`resource.invalidated.keys` validates shape, not vocabulary.** A
|
|
23
|
+
vocabulary-strict gate would reject an entire frame when a newer server names
|
|
24
|
+
a key this client has not learned. Today an unknown key matches no query and
|
|
25
|
+
the rest of the frame still lands.
|
|
26
|
+
- **`version` is an entity timestamp, never request time.** A request-time value
|
|
27
|
+
silently re-opens the race the client's version gate exists to close.
|
|
28
|
+
- **`v`/`timestamp` are optional on `resource.invalidated`, `resync` and
|
|
29
|
+
`connected`, and required nowhere else.** The invalidation frame was already
|
|
30
|
+
on the wire without them; requiring them would make promotion a breaking
|
|
31
|
+
change dressed as a tidy-up.
|
|
32
|
+
|
|
33
|
+
## Public API
|
|
34
|
+
|
|
35
|
+
| Export | Description |
|
|
36
|
+
| ------------------------------------- | ------------------------------------------------- |
|
|
37
|
+
| `UserSseEventSchema` / `UserSseEvent` | The union. OpenAPI component `UserSseEvent`. |
|
|
38
|
+
| `ResourceInvalidatedEventSchema` | Cache-staleness signal carrying `ResourceKey[]` |
|
|
39
|
+
| `UserEventResyncSchema` | "Could not name what changed; re-read everything" |
|
|
40
|
+
| `UserEventConnectedSchema` | Transport-level open frame |
|
|
41
|
+
| `MAX_INVALIDATION_KEYS` | Per-frame key cap (`16`) |
|
|
42
|
+
| `USER_EVENT_RESYNC_REASONS` | `listen-recovered` \| `payload-over-cap` |
|
|
43
|
+
|
|
44
|
+
## Dependencies
|
|
45
|
+
|
|
46
|
+
- `zod` — schemas are canonical, types are inferred.
|
|
47
|
+
- `../resource-keys` — `ResourceKey`, `isResourceKeyShape`.
|
|
48
|
+
|
|
49
|
+
Nothing else. In particular: no `notifications/`, no `action-items/`.
|
|
50
|
+
|
|
51
|
+
## How this differs from the two adjacent vocabularies
|
|
52
|
+
|
|
53
|
+
Three things in this package can look like "a notification". They are distinct,
|
|
54
|
+
and ADR-CONT-104 forbids collapsing them:
|
|
55
|
+
|
|
56
|
+
- **`NotificationKind`** (`../notifications`) names a message that **leaves the
|
|
57
|
+
building** to a durable address, composed by a render pipeline into prose
|
|
58
|
+
carrying a brand and a year.
|
|
59
|
+
- **`ActionItemKind`** (`../action-items`) names **standing state** — a decision
|
|
60
|
+
you owe — re-derived from source rows on every read, which stops existing when
|
|
61
|
+
someone resolves it.
|
|
62
|
+
- **A user event** is neither. It is a single instant on a socket, unreplayable
|
|
63
|
+
and unaddressable, whose entire meaning is "your cached view is stale".
|
|
64
|
+
|
|
65
|
+
A domain that needs durability emits the durable thing **and** a user event —
|
|
66
|
+
never a user event alone.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# user-events/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the claims `../README.md` and ADR-CONT-107 make that the compiler cannot.
|
|
6
|
+
|
|
7
|
+
- `user-events.test.ts` — four things a type signature does not say:
|
|
8
|
+
1. **Promotion did not change the wire.** The load-bearing test of the whole
|
|
9
|
+
module. `resource.invalidated` was already deployed, emitted by the backend
|
|
10
|
+
and re-declared structurally in the app, carrying neither `v` nor
|
|
11
|
+
`timestamp`. The schema is pinned against that exact object. If it fails,
|
|
12
|
+
publishing the shape broke every client already running.
|
|
13
|
+
2. **An unknown key type is ACCEPTED.** The reason `keys` validates shape and
|
|
14
|
+
not vocabulary. A newer server naming a key this contracts version has not
|
|
15
|
+
learned must cost that one key, never the whole frame. A strict gate here
|
|
16
|
+
would be the only place in the pipeline that hard-fails on version skew.
|
|
17
|
+
3. **An unmodeled frame `type` is REJECTED.** So the client logs it instead of
|
|
18
|
+
silently dropping it. The two rules pull in opposite directions on purpose:
|
|
19
|
+
lenient about payload vocabulary, strict about frame identity.
|
|
20
|
+
4. **`resource.invalidated` rejects an `ActionItem`-shaped payload.** What
|
|
21
|
+
stops the future "just reuse ActionItem on the stream" change. An action
|
|
22
|
+
item is re-derived on every read and resolves when someone acts; it cannot
|
|
23
|
+
be delivered once and forgotten.
|
|
24
|
+
|
|
25
|
+
## Invariants
|
|
26
|
+
|
|
27
|
+
- These tests assert the WIRE, never behaviour — there is no behaviour here.
|
|
28
|
+
Anything needing a socket, a dispatcher or a database belongs in backend's
|
|
29
|
+
`src/user-events/__tests__/`, not here.
|
|
30
|
+
- The disjointness test reads `ACTION_ITEM_KINDS` rather than restating the
|
|
31
|
+
kinds. A hand-copied list would drift and start passing vacuously.
|
|
32
|
+
- Negative cases assert `.success === false` on a frame that is otherwise
|
|
33
|
+
well-formed, mutating one field. A negative test that hand-builds a broken
|
|
34
|
+
object can pass for the wrong reason.
|
|
35
|
+
|
|
36
|
+
## Public API
|
|
37
|
+
|
|
38
|
+
None — test-only.
|
|
39
|
+
|
|
40
|
+
## Dependencies
|
|
41
|
+
|
|
42
|
+
`vitest`, the sibling module under test, `../../action-items` (for the real kind
|
|
43
|
+
list), and `../../resource-keys` (for `isResourceKeyShape`).
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-event wire contract's invariants, as tests rather than prose.
|
|
3
|
+
*
|
|
4
|
+
* These lock the claims the README makes that a compiler cannot: that promoting
|
|
5
|
+
* `resource.invalidated` did not change the wire, that key validation is
|
|
6
|
+
* forward-compatible across version skew, and that this vocabulary is separate
|
|
7
|
+
* from the two adjacent ones (ADR-CONT-104).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
MAX_INVALIDATION_KEYS,
|
|
12
|
+
ResourceInvalidatedEventSchema,
|
|
13
|
+
USER_EVENT_RESYNC_REASONS,
|
|
14
|
+
UserEventConnectedSchema,
|
|
15
|
+
UserEventResyncSchema,
|
|
16
|
+
UserSseEventSchema,
|
|
17
|
+
} from "../schemas.js";
|
|
18
|
+
import { ACTION_ITEM_KINDS } from "../../action-items/index.js";
|
|
19
|
+
import { isResourceKeyShape } from "../../resource-keys.js";
|
|
20
|
+
|
|
21
|
+
const ORG_ID = "11111111-1111-4111-8111-111111111111";
|
|
22
|
+
const DOC_ID = "22222222-2222-4222-8222-222222222222";
|
|
23
|
+
|
|
24
|
+
describe("resource.invalidated — promotion must not change the wire", () => {
|
|
25
|
+
it("parses the EXACT object the backend emits today", () => {
|
|
26
|
+
// Verbatim shape of `ResourceInvalidationEvent` in backend
|
|
27
|
+
// src/chat/execution/resource-invalidation.ts: no `v`, no `timestamp`.
|
|
28
|
+
// If this fails, promoting the interface into contracts broke the wire and
|
|
29
|
+
// every already-deployed client stops receiving invalidations.
|
|
30
|
+
const onTheWireToday = {
|
|
31
|
+
type: "resource.invalidated",
|
|
32
|
+
keys: [{ type: "actionItems", orgId: ORG_ID }],
|
|
33
|
+
traceId: "trace-1",
|
|
34
|
+
version: 1785312000000,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
expect(
|
|
38
|
+
ResourceInvalidatedEventSchema.safeParse(onTheWireToday).success,
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts a frame with neither traceId nor the base fields", () => {
|
|
43
|
+
expect(
|
|
44
|
+
ResourceInvalidatedEventSchema.safeParse({
|
|
45
|
+
type: "resource.invalidated",
|
|
46
|
+
keys: [
|
|
47
|
+
{ type: "companyMdAccessRequests", orgId: ORG_ID, docId: DOC_ID },
|
|
48
|
+
],
|
|
49
|
+
version: 0,
|
|
50
|
+
}).success,
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects a request-time version that is not an epoch integer", () => {
|
|
55
|
+
const result = ResourceInvalidatedEventSchema.safeParse({
|
|
56
|
+
type: "resource.invalidated",
|
|
57
|
+
keys: [{ type: "actionItems", orgId: ORG_ID }],
|
|
58
|
+
version: 1.5,
|
|
59
|
+
});
|
|
60
|
+
expect(result.success).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects an empty key list — a frame naming nothing is a bug, not a no-op", () => {
|
|
64
|
+
expect(
|
|
65
|
+
ResourceInvalidatedEventSchema.safeParse({
|
|
66
|
+
type: "resource.invalidated",
|
|
67
|
+
keys: [],
|
|
68
|
+
version: 1,
|
|
69
|
+
}).success,
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("caps keys so the envelope cannot approach the NOTIFY payload limit", () => {
|
|
74
|
+
const overCap = Array.from({ length: MAX_INVALIDATION_KEYS + 1 }, () => ({
|
|
75
|
+
type: "actionItems",
|
|
76
|
+
orgId: ORG_ID,
|
|
77
|
+
}));
|
|
78
|
+
expect(
|
|
79
|
+
ResourceInvalidatedEventSchema.safeParse({
|
|
80
|
+
type: "resource.invalidated",
|
|
81
|
+
keys: overCap,
|
|
82
|
+
version: 1,
|
|
83
|
+
}).success,
|
|
84
|
+
).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("key validation is forward-compatible across version skew", () => {
|
|
89
|
+
it("ACCEPTS a key type this contracts version has never heard of", () => {
|
|
90
|
+
// The whole reason `keys` validates shape and not vocabulary. A newer
|
|
91
|
+
// server naming a key we do not know must not cost us the ENTIRE frame —
|
|
92
|
+
// the unknown key matches no query, the known ones still land.
|
|
93
|
+
const result = ResourceInvalidatedEventSchema.safeParse({
|
|
94
|
+
type: "resource.invalidated",
|
|
95
|
+
keys: [
|
|
96
|
+
{ type: "somethingShippedAfterThisClient", orgId: ORG_ID },
|
|
97
|
+
{ type: "actionItems", orgId: ORG_ID },
|
|
98
|
+
],
|
|
99
|
+
version: 1,
|
|
100
|
+
});
|
|
101
|
+
expect(result.success).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("rejects a key with no scope discriminator", () => {
|
|
105
|
+
expect(isResourceKeyShape({ type: "actionItems" })).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects a key with two scope discriminators", () => {
|
|
109
|
+
expect(
|
|
110
|
+
isResourceKeyShape({ type: "actionItems", orgId: ORG_ID, userId: "u1" }),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects non-objects and an empty type", () => {
|
|
115
|
+
expect(isResourceKeyShape(null)).toBe(false);
|
|
116
|
+
expect(isResourceKeyShape("actionItems")).toBe(false);
|
|
117
|
+
expect(isResourceKeyShape({ type: "", orgId: ORG_ID })).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("resync", () => {
|
|
122
|
+
it("accepts every declared reason", () => {
|
|
123
|
+
for (const reason of USER_EVENT_RESYNC_REASONS) {
|
|
124
|
+
expect(
|
|
125
|
+
UserEventResyncSchema.safeParse({ type: "resync", reason }).success,
|
|
126
|
+
).toBe(true);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects an undeclared reason", () => {
|
|
131
|
+
expect(
|
|
132
|
+
UserEventResyncSchema.safeParse({ type: "resync", reason: "because" })
|
|
133
|
+
.success,
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("the union", () => {
|
|
139
|
+
it("discriminates every member on `type`", () => {
|
|
140
|
+
const frames = [
|
|
141
|
+
{
|
|
142
|
+
type: "resource.invalidated",
|
|
143
|
+
keys: [{ type: "actionItems", orgId: ORG_ID }],
|
|
144
|
+
version: 1,
|
|
145
|
+
},
|
|
146
|
+
{ type: "resync", reason: "listen-recovered" },
|
|
147
|
+
{ type: "connected" },
|
|
148
|
+
];
|
|
149
|
+
for (const frame of frames) {
|
|
150
|
+
expect(UserSseEventSchema.safeParse(frame).success).toBe(true);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rejects an unmodeled type so the client LOGS it rather than silently dropping", () => {
|
|
155
|
+
expect(
|
|
156
|
+
UserSseEventSchema.safeParse({ type: "notification.created", id: "n1" })
|
|
157
|
+
.success,
|
|
158
|
+
).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("models `connected` at all — an unmodeled open frame would log a parse error on every connect", () => {
|
|
162
|
+
expect(
|
|
163
|
+
UserEventConnectedSchema.safeParse({ type: "connected" }).success,
|
|
164
|
+
).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("boundary with the two adjacent vocabularies (ADR-CONT-104)", () => {
|
|
169
|
+
it("shares no member name with ActionItemKind", () => {
|
|
170
|
+
// Not symmetry policing — a shared string would make one of the two
|
|
171
|
+
// meanings lie, since an action item is standing state and a user event is
|
|
172
|
+
// a single unreplayable instant.
|
|
173
|
+
const frameTypes = new Set(["resource.invalidated", "resync", "connected"]);
|
|
174
|
+
for (const kind of ACTION_ITEM_KINDS) {
|
|
175
|
+
expect(frameTypes.has(kind)).toBe(false);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("REJECTS an ActionItem-shaped payload", () => {
|
|
180
|
+
// The test that stops the future "just reuse ActionItem on the stream" PR.
|
|
181
|
+
// An action item is re-derived on every read and resolves when someone
|
|
182
|
+
// acts; it cannot be delivered once and forgotten.
|
|
183
|
+
const actionItemShaped = {
|
|
184
|
+
type: "resource.invalidated",
|
|
185
|
+
id: "companyMd.access_request_pending:req-1",
|
|
186
|
+
kind: "companyMd.access_request_pending",
|
|
187
|
+
target: { type: "company_md", id: DOC_ID },
|
|
188
|
+
unitPath: "acme.sales",
|
|
189
|
+
title: "Maya Chen wants access",
|
|
190
|
+
href: "/@acme/md/doc-1?share=1",
|
|
191
|
+
};
|
|
192
|
+
expect(
|
|
193
|
+
ResourceInvalidatedEventSchema.safeParse(actionItemShaped).success,
|
|
194
|
+
).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-events/ — the frames any domain may put on ONE user's connection.
|
|
3
|
+
*
|
|
4
|
+
* See ./README.md for the domain and for why this is neither
|
|
5
|
+
* `NotificationKind` nor `ActionItemKind`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
MAX_INVALIDATION_KEYS,
|
|
10
|
+
ResourceInvalidatedEventSchema,
|
|
11
|
+
USER_EVENT_RESYNC_REASONS,
|
|
12
|
+
UserEventBaseSchema,
|
|
13
|
+
UserEventConnectedSchema,
|
|
14
|
+
UserEventResyncReasonSchema,
|
|
15
|
+
UserEventResyncSchema,
|
|
16
|
+
UserSseEventSchema,
|
|
17
|
+
} from "./schemas";
|
|
18
|
+
|
|
19
|
+
export type {
|
|
20
|
+
ResourceInvalidatedEvent,
|
|
21
|
+
UserEventConnected,
|
|
22
|
+
UserEventResync,
|
|
23
|
+
UserEventResyncReason,
|
|
24
|
+
UserSseEvent,
|
|
25
|
+
} from "./schemas";
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user-scoped push envelope — every frame the server sends to ONE
|
|
3
|
+
* authenticated user on a long-lived connection, regardless of domain.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT A FIFTH `<Domain>SseEvent`. `ChatSseEvent`,
|
|
6
|
+
* `ExecutionSseEvent`, `CompanyMdCollabSseEvent` and `ImpersonationSseEvent`
|
|
7
|
+
* each describe ONE domain's traffic on ONE route. This union describes the
|
|
8
|
+
* frames any domain may put on the user's connection WITHOUT owning a route or
|
|
9
|
+
* a union of its own — which is exactly what `resource.invalidated` has been
|
|
10
|
+
* doing unpublished (backend `src/chat/execution/resource-invalidation.ts`,
|
|
11
|
+
* redeclared structurally in app `src/hooks/useChatEvents.ts`) since before
|
|
12
|
+
* there was a place to put it.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS STREAM IS, STATED SO NOBODY ASSUMES MORE:
|
|
15
|
+
*
|
|
16
|
+
* It is a wake-up channel plus ephemeral cache hints. It is NOT a general
|
|
17
|
+
* ordered event log. Publishing a user event grants NO replay guarantee.
|
|
18
|
+
*
|
|
19
|
+
* Two rules encode that, and both are load-bearing:
|
|
20
|
+
*
|
|
21
|
+
* - **No frame carries an SSE `id:` line.** An id implies a resumable position;
|
|
22
|
+
* there is no log behind this stream, so an id would be a lie. (Contrast
|
|
23
|
+
* `ExecutionSseEvent`, whose `eventSequence` doubles as `Last-Event-ID`
|
|
24
|
+
* because it projects a durable `execution_events` row — ADR-CONT-066.)
|
|
25
|
+
* - **Delivery is edge-triggered only.** Missing a frame may leave caches stale
|
|
26
|
+
* until the next authoritative refresh. That is acceptable because NO
|
|
27
|
+
* CORRECTNESS DEPENDS ON INVALIDATIONS — every frame means "re-read this",
|
|
28
|
+
* and the read is the authority. Nothing durable may ever be hung off this
|
|
29
|
+
* stream without first giving it a real commit-ordered log.
|
|
30
|
+
*/
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import type { ResourceKey } from "../resource-keys";
|
|
33
|
+
import { isResourceKeyShape } from "../resource-keys";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Local, not chat's. `chat/schemas.ts` owns a structurally identical helper;
|
|
37
|
+
* importing it would make a chat frame change a user-events breaking change.
|
|
38
|
+
*/
|
|
39
|
+
const IsoDateString = z.string().datetime();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fields every frame carries.
|
|
43
|
+
*
|
|
44
|
+
* Structurally identical to chat's `BaseEventSchema` on purpose, so folding
|
|
45
|
+
* chat onto this envelope later is a no-op — but deliberately NOT the same
|
|
46
|
+
* object, for the reason above.
|
|
47
|
+
*/
|
|
48
|
+
export const UserEventBaseSchema = z.object({
|
|
49
|
+
v: z.literal(1),
|
|
50
|
+
timestamp: IsoDateString,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// resource.invalidated — promoted verbatim from the ad-hoc backend interface
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/** Upper bound on keys per frame. Keeps the envelope far under NOTIFY's limit. */
|
|
58
|
+
export const MAX_INVALIDATION_KEYS = 16;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A cache-staleness signal. The client refreshes the named keys and shows the
|
|
62
|
+
* user nothing.
|
|
63
|
+
*
|
|
64
|
+
* `keys` validates SHAPE, not VOCABULARY — see `isResourceKeyShape` for why a
|
|
65
|
+
* vocabulary-strict gate here would reject whole frames on version skew.
|
|
66
|
+
*
|
|
67
|
+
* `v` and `timestamp` are OPTIONAL HERE AND NOWHERE ELSE. The frame already on
|
|
68
|
+
* the wire carries neither; requiring them would make promotion a breaking
|
|
69
|
+
* change dressed up as a tidy-up. Same carve-out, same reason, as
|
|
70
|
+
* `CompanyMdCollabConnectedEventSchema`.
|
|
71
|
+
*/
|
|
72
|
+
export const ResourceInvalidatedEventSchema = UserEventBaseSchema.partial({
|
|
73
|
+
v: true,
|
|
74
|
+
timestamp: true,
|
|
75
|
+
}).extend({
|
|
76
|
+
type: z.literal("resource.invalidated"),
|
|
77
|
+
keys: z
|
|
78
|
+
.array(z.custom<ResourceKey>(isResourceKeyShape))
|
|
79
|
+
.min(1)
|
|
80
|
+
.max(MAX_INVALIDATION_KEYS),
|
|
81
|
+
/** Correlates the frame with the mutation that caused it. */
|
|
82
|
+
traceId: z.string().optional(),
|
|
83
|
+
/**
|
|
84
|
+
* Entity version — MUST be the entity's own `updatedAt`/`resolvedAt` as epoch
|
|
85
|
+
* ms, NEVER request time. The client's version gate compares it against the
|
|
86
|
+
* cached payload's `version`; a request-time value silently re-opens the race
|
|
87
|
+
* the gate exists to close. (The invariant the backend module already states,
|
|
88
|
+
* published here so it binds the wire rather than one emitter.)
|
|
89
|
+
*/
|
|
90
|
+
version: z.number().int().nonnegative(),
|
|
91
|
+
});
|
|
92
|
+
export type ResourceInvalidatedEvent = z.infer<
|
|
93
|
+
typeof ResourceInvalidatedEventSchema
|
|
94
|
+
>;
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// resync — freshness when precision is unavailable
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why the server could not name what changed.
|
|
102
|
+
*
|
|
103
|
+
* - `listen-recovered` — the LISTEN connection was down and frames were missed.
|
|
104
|
+
* - `payload-over-cap` — a single frame exceeded the NOTIFY payload limit.
|
|
105
|
+
*/
|
|
106
|
+
export const USER_EVENT_RESYNC_REASONS = [
|
|
107
|
+
"listen-recovered",
|
|
108
|
+
"payload-over-cap",
|
|
109
|
+
] as const;
|
|
110
|
+
export const UserEventResyncReasonSchema = z.enum(USER_EVENT_RESYNC_REASONS);
|
|
111
|
+
export type UserEventResyncReason = z.infer<typeof UserEventResyncReasonSchema>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* "I could not tell you precisely what changed; re-read everything."
|
|
115
|
+
*
|
|
116
|
+
* The client invalidates every resource it subscribes to. One frame type covers
|
|
117
|
+
* both reasons because they mean the same thing to a client, and because the
|
|
118
|
+
* alternative in each case is worse:
|
|
119
|
+
*
|
|
120
|
+
* - Truncating an over-cap frame yields a SHORTENED `keys` array — a stale
|
|
121
|
+
* cache that looks fresh, which is undetectable downstream.
|
|
122
|
+
* - Dropping it instead leaves that user stale until their next reconnect,
|
|
123
|
+
* which may be hours away.
|
|
124
|
+
*
|
|
125
|
+
* **Precision is expendable; freshness is not.** A resync costs one refetch
|
|
126
|
+
* wave and is always correct.
|
|
127
|
+
*/
|
|
128
|
+
export const UserEventResyncSchema = UserEventBaseSchema.partial({
|
|
129
|
+
v: true,
|
|
130
|
+
timestamp: true,
|
|
131
|
+
}).extend({
|
|
132
|
+
type: z.literal("resync"),
|
|
133
|
+
reason: UserEventResyncReasonSchema,
|
|
134
|
+
});
|
|
135
|
+
export type UserEventResync = z.infer<typeof UserEventResyncSchema>;
|
|
136
|
+
|
|
137
|
+
// =============================================================================
|
|
138
|
+
// connected — transport-level
|
|
139
|
+
// =============================================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The stream is open.
|
|
143
|
+
*
|
|
144
|
+
* Modeled for the same reason `CompanyMdCollabConnectedEventSchema` is: without
|
|
145
|
+
* it, a client that validates EVERY frame logs a parse error on a perfectly
|
|
146
|
+
* normal connect.
|
|
147
|
+
*/
|
|
148
|
+
export const UserEventConnectedSchema = UserEventBaseSchema.partial({
|
|
149
|
+
v: true,
|
|
150
|
+
timestamp: true,
|
|
151
|
+
}).extend({ type: z.literal("connected") });
|
|
152
|
+
export type UserEventConnected = z.infer<typeof UserEventConnectedSchema>;
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// The union
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Every frame the user-scoped stream emits.
|
|
160
|
+
*
|
|
161
|
+
* Registered as the OpenAPI component `UserSseEvent`, per the `ChatSseEvent` /
|
|
162
|
+
* `ExecutionSseEvent` / `CompanyMdCollabSseEvent` precedent.
|
|
163
|
+
*
|
|
164
|
+
* `server_drain` is deliberately NOT modeled: the stream already emits it under
|
|
165
|
+
* its own SSE `event:` name and the client binds a dedicated out-of-band
|
|
166
|
+
* listener for it. Adding it here would create two handling paths for one frame.
|
|
167
|
+
*/
|
|
168
|
+
export const UserSseEventSchema = z.discriminatedUnion("type", [
|
|
169
|
+
ResourceInvalidatedEventSchema,
|
|
170
|
+
UserEventResyncSchema,
|
|
171
|
+
UserEventConnectedSchema,
|
|
172
|
+
]);
|
|
173
|
+
export type UserSseEvent = z.infer<typeof UserSseEventSchema>;
|