@indigoai-us/hq-cloud 6.14.49 → 6.15.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/.github/workflows/unreleased-commits-nag.yml +256 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +153 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +11 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
- package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.test.js +51 -0
- package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +11 -12
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +173 -52
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex-knowledge.d.ts +7 -0
- package/dist/cli/reindex-knowledge.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.js +389 -0
- package/dist/cli/reindex-knowledge.js.map +1 -0
- package/dist/cli/reindex-knowledge.test.d.ts +12 -0
- package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
- package/dist/cli/reindex-knowledge.test.js +260 -0
- package/dist/cli/reindex-knowledge.test.js.map +1 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +8 -0
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +8 -2
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +85 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +390 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
- package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
- package/dist/cli/rescue-settings-reconcile.test.js +523 -0
- package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +81 -51
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +11 -19
- package/dist/journal.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +8 -25
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +23 -48
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +6 -32
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/feature-flags.d.ts +20 -117
- package/dist/sync/feature-flags.d.ts.map +1 -1
- package/dist/sync/feature-flags.js +24 -147
- package/dist/sync/feature-flags.js.map +1 -1
- package/dist/sync/feature-flags.test.js +15 -61
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/lease-client.d.ts +67 -0
- package/dist/sync/lease-client.d.ts.map +1 -0
- package/dist/sync/lease-client.js +158 -0
- package/dist/sync/lease-client.js.map +1 -0
- package/dist/sync/lease-client.test.d.ts +2 -0
- package/dist/sync/lease-client.test.d.ts.map +1 -0
- package/dist/sync/lease-client.test.js +119 -0
- package/dist/sync/lease-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +8 -19
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +6 -50
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +6 -57
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/realtime-rollout.d.ts +85 -0
- package/dist/sync/realtime-rollout.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.js +206 -0
- package/dist/sync/realtime-rollout.js.map +1 -0
- package/dist/sync/realtime-rollout.test.d.ts +2 -0
- package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
- package/dist/sync/realtime-rollout.test.js +71 -0
- package/dist/sync/realtime-rollout.test.js.map +1 -0
- package/dist/sync/state-store.d.ts +40 -0
- package/dist/sync/state-store.d.ts.map +1 -0
- package/dist/sync/state-store.js +680 -0
- package/dist/sync/state-store.js.map +1 -0
- package/dist/sync/state-store.test.d.ts +2 -0
- package/dist/sync/state-store.test.d.ts.map +1 -0
- package/dist/sync/state-store.test.js +159 -0
- package/dist/sync/state-store.test.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +176 -32
- package/src/bin/sync-runner-watch-routes.test.ts +71 -0
- package/src/bin/sync-runner-watch-routes.ts +27 -0
- package/src/bin/sync-runner.test.ts +188 -53
- package/src/bin/sync-runner.ts +11 -12
- package/src/cli/reindex-knowledge.test.ts +307 -0
- package/src/cli/reindex-knowledge.ts +450 -0
- package/src/cli/reindex.ts +9 -0
- package/src/cli/rescue-classify-ordering.test.ts +9 -3
- package/src/cli/rescue-core.ts +459 -1
- package/src/cli/rescue-settings-reconcile.test.ts +637 -0
- package/src/index.ts +2 -4
- package/src/journal.test.ts +19 -21
- package/src/journal.ts +87 -54
- package/src/sync/event-sync.test.ts +9 -44
- package/src/sync/event-sync.ts +26 -52
- package/src/sync/feature-flags.test.ts +22 -72
- package/src/sync/feature-flags.ts +32 -199
- package/src/sync/index.ts +2 -4
- package/src/sync/lease-client.test.ts +128 -0
- package/src/sync/lease-client.ts +207 -0
- package/src/sync/push-receiver.test.ts +6 -58
- package/src/sync/push-receiver.ts +9 -74
- package/src/sync/realtime-rollout.test.ts +86 -0
- package/src/sync/realtime-rollout.ts +262 -0
- package/src/sync/state-store.test.ts +194 -0
- package/src/sync/state-store.ts +727 -0
- package/src/types.ts +14 -0
- package/test/e2e/sync/transient-company-leg.test.ts +8 -4
|
@@ -1,229 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Local control for the dormant event-sync wiring.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* ───────────────
|
|
9
|
-
* The event-driven push pipeline must default OFF for all tenants and be
|
|
10
|
-
* enabled per-tenant ("OFF for all tenants by default, ON for Indigo only").
|
|
11
|
-
* This module provides the read-only seam the watcher (and a later receiver)
|
|
12
|
-
* consult at start time to decide whether to emit/ship PushEvents at all:
|
|
13
|
-
* - {@link EventDrivenPushFlagProvider} — the small read-only seam every
|
|
14
|
-
* caller consults. Returns a boolean for a given tenantId.
|
|
15
|
-
* - {@link EnvTenantListFlagProvider} — production implementation. Reads
|
|
16
|
-
* a comma-separated allow-list from {@link FEATURE_FLAG_TENANTS_ENV_VAR}
|
|
17
|
-
* AND honors the legacy global `HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED=true`
|
|
18
|
-
* as a backwards-compat global override. The legacy global wins when set
|
|
19
|
-
* — it enables all tenants.
|
|
20
|
-
* - {@link StaticFlagProvider} — small test helper. Constructor takes an
|
|
21
|
-
* iterable of enabled tenant IDs. Used by watcher + feature-flag tests.
|
|
22
|
-
* - {@link defaultFlagProvider} — factory callers default to when no
|
|
23
|
-
* provider is explicitly supplied.
|
|
24
|
-
*
|
|
25
|
-
* Validation posture
|
|
26
|
-
* ──────────────────
|
|
27
|
-
* Tenant IDs from the env are trimmed, empty entries are dropped, and
|
|
28
|
-
* entries containing internal whitespace are warn-and-skipped (NOT thrown).
|
|
29
|
-
* The watcher runs inside a long-lived daemon — a malformed env var must
|
|
30
|
-
* not crash daemon startup; it must surface in logs and fall through to the
|
|
31
|
-
* default-OFF behavior for the misconfigured entry.
|
|
32
|
-
*
|
|
33
|
-
* @see src/watcher.ts — the TreeWatcher consults this seam at start time to
|
|
34
|
-
* decide whether to emit + ship PushEvents (dormant when OFF).
|
|
4
|
+
* U15 removes client-side positive selectors. Environment values, tenant IDs,
|
|
5
|
+
* and cached identity cannot grant authority; the authenticated server
|
|
6
|
+
* inventory is the sole positive authority. This module preserves only the
|
|
7
|
+
* operator's local off-switch.
|
|
35
8
|
*/
|
|
36
9
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Env var holding a comma-separated allow-list of tenant IDs for which the
|
|
41
|
-
* event-driven push pipeline is enabled. Empty/unset means no tenants are
|
|
42
|
-
* enabled (default OFF for all). Example: `indigo,acme`.
|
|
43
|
-
*
|
|
44
|
-
* Tenant matching is exact and case-sensitive after trimming. Whitespace
|
|
45
|
-
* around commas is tolerated; internal whitespace within a tenant ID is
|
|
46
|
-
* not (warned + skipped — see `parseTenantList` below).
|
|
47
|
-
*/
|
|
48
|
-
export const FEATURE_FLAG_TENANTS_ENV_VAR =
|
|
49
|
-
"HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED_TENANTS";
|
|
10
|
+
/** A local-only kill switch. Any other value is deliberately non-authorizing. */
|
|
11
|
+
export const EVENT_SYNC_LOCAL_DISABLE_ENV_VAR = "HQ_SYNC_EVENT_SYNC";
|
|
50
12
|
|
|
51
13
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* enables ALL tenants" so a single-flag rollout (or local dev) keeps working.
|
|
55
|
-
*
|
|
56
|
-
* Operators flipping rollout via deploy-time config should prefer the
|
|
57
|
-
* per-tenant {@link FEATURE_FLAG_TENANTS_ENV_VAR} instead.
|
|
58
|
-
*/
|
|
59
|
-
export const FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR =
|
|
60
|
-
"HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED";
|
|
61
|
-
|
|
62
|
-
// ─── Public types ──────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Read-only seam for the per-tenant event-driven push feature flag.
|
|
66
|
-
*
|
|
67
|
-
* Implementations are pure / synchronous. Callers MUST treat the result as
|
|
68
|
-
* the truth at the moment of the call — a deploy-time env-var change does
|
|
69
|
-
* NOT propagate to running daemons until they restart.
|
|
14
|
+
* Compatibility seam consumed by the existing emitter. Production event-sync
|
|
15
|
+
* supplies it only after authenticated server inventory has granted access.
|
|
70
16
|
*/
|
|
71
17
|
export interface EventDrivenPushFlagProvider {
|
|
72
|
-
|
|
73
|
-
* Returns true iff the event-driven push pipeline is enabled for
|
|
74
|
-
* `tenantId`. Defaults to false. Case-sensitive exact match against the
|
|
75
|
-
* configured allow-list (or the legacy global override).
|
|
76
|
-
*/
|
|
77
|
-
isEnabled(tenantId: string): boolean;
|
|
18
|
+
isEnabled(scopeId: string): boolean;
|
|
78
19
|
}
|
|
79
20
|
|
|
80
|
-
/**
|
|
81
|
-
* Optional injection seam for warnings emitted while parsing the env var
|
|
82
|
-
* (invalid entries). Defaults to `console.warn`.
|
|
83
|
-
*/
|
|
21
|
+
/** Deprecated inert compatibility shape; no environment is ever read. */
|
|
84
22
|
export type FlagProviderWarnFn = (
|
|
85
23
|
message: string,
|
|
86
24
|
context: Record<string, unknown>,
|
|
87
25
|
) => void;
|
|
88
26
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Options for {@link EnvTenantListFlagProvider}.
|
|
93
|
-
*/
|
|
27
|
+
/** Deprecated inert compatibility shape; no environment is ever read. */
|
|
94
28
|
export interface EnvTenantListFlagProviderOptions {
|
|
95
|
-
/**
|
|
96
|
-
* Env snapshot. Defaults to `process.env`. Tests inject `{}` or a
|
|
97
|
-
* synthetic object so they don't mutate real env state.
|
|
98
|
-
*/
|
|
99
29
|
env?: Record<string, string | undefined>;
|
|
100
|
-
/**
|
|
101
|
-
* Where to send "invalid entry, skipping" warnings. Defaults to
|
|
102
|
-
* `console.warn`.
|
|
103
|
-
*/
|
|
104
30
|
warn?: FlagProviderWarnFn;
|
|
105
31
|
}
|
|
106
32
|
|
|
107
33
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* The legacy {@link FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR} (set to literal
|
|
112
|
-
* `"true"`) acts as a global override: when set, `isEnabled()` returns
|
|
113
|
-
* true for every tenant.
|
|
114
|
-
*
|
|
115
|
-
* Parse rules:
|
|
116
|
-
* - Empty / unset {@link FEATURE_FLAG_TENANTS_ENV_VAR} → no tenants enabled.
|
|
117
|
-
* - Whitespace around commas is tolerated and trimmed.
|
|
118
|
-
* - Empty entries (`",,foo,"`) are silently dropped.
|
|
119
|
-
* - Entries with INTERNAL whitespace (`"indigo team"`) are warn-and-skipped.
|
|
120
|
-
* - Matching is exact + case-sensitive.
|
|
121
|
-
*/
|
|
122
|
-
export class EnvTenantListFlagProvider implements EventDrivenPushFlagProvider {
|
|
123
|
-
private readonly tenants: ReadonlySet<string>;
|
|
124
|
-
private readonly legacyGlobal: boolean;
|
|
125
|
-
|
|
126
|
-
constructor(opts: EnvTenantListFlagProviderOptions = {}) {
|
|
127
|
-
const env = opts.env ?? process.env;
|
|
128
|
-
const warn = opts.warn ?? defaultWarn;
|
|
129
|
-
this.tenants = parseTenantList(env[FEATURE_FLAG_TENANTS_ENV_VAR], warn);
|
|
130
|
-
this.legacyGlobal = env[FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR] === "true";
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
isEnabled(tenantId: string): boolean {
|
|
134
|
-
if (this.legacyGlobal) return true;
|
|
135
|
-
return this.tenants.has(tenantId);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Debug-only accessor — returns a defensive copy of the cached allow-list.
|
|
140
|
-
* Used by tests and the daemon's startup log line.
|
|
141
|
-
*/
|
|
142
|
-
get enabledTenants(): ReadonlySet<string> {
|
|
143
|
-
return new Set(this.tenants);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Debug-only accessor — true iff the legacy global override is in effect. */
|
|
147
|
-
get globalOverride(): boolean {
|
|
148
|
-
return this.legacyGlobal;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ─── Static provider (tests) ───────────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* In-memory provider for unit tests. The constructor takes the set of
|
|
156
|
-
* enabled tenant IDs; `isEnabled()` is a plain `Set.has` check.
|
|
157
|
-
*
|
|
158
|
-
* Production callers should NEVER construct this — use
|
|
159
|
-
* {@link EnvTenantListFlagProvider} via {@link defaultFlagProvider}.
|
|
34
|
+
* Compatibility fixture for older direct emitter tests. It represents a
|
|
35
|
+
* previously authenticated server inventory result; the supplied legacy value
|
|
36
|
+
* is deliberately ignored, so it cannot select a tenant or grant by email.
|
|
160
37
|
*/
|
|
161
38
|
export class StaticFlagProvider implements EventDrivenPushFlagProvider {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
constructor(enabledTenants: Iterable<string>) {
|
|
165
|
-
this.tenants = new Set(enabledTenants);
|
|
166
|
-
}
|
|
39
|
+
constructor(_authenticatedLegacyInventory: unknown) {}
|
|
167
40
|
|
|
168
|
-
isEnabled(
|
|
169
|
-
return
|
|
41
|
+
isEnabled(_scopeId: string): boolean {
|
|
42
|
+
return true;
|
|
170
43
|
}
|
|
171
44
|
}
|
|
172
45
|
|
|
173
|
-
// ─── Factory ───────────────────────────────────────────────────────────────
|
|
174
|
-
|
|
175
46
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
*
|
|
191
|
-
* Kept as a free function so unit tests can call it via the class accessor
|
|
192
|
-
* with a synthetic warn spy without exporting an internal symbol.
|
|
193
|
-
*/
|
|
194
|
-
function parseTenantList(
|
|
195
|
-
raw: string | undefined,
|
|
196
|
-
warn: FlagProviderWarnFn,
|
|
197
|
-
): ReadonlySet<string> {
|
|
198
|
-
if (raw === undefined || raw === "") return new Set<string>();
|
|
199
|
-
const out = new Set<string>();
|
|
200
|
-
for (const piece of raw.split(",")) {
|
|
201
|
-
const trimmed = piece.trim();
|
|
202
|
-
if (trimmed === "") {
|
|
203
|
-
// Empty slot (`",,foo,"` or trailing commas) — silently skipped.
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (/\s/.test(trimmed)) {
|
|
207
|
-
// Internal whitespace — operator misconfiguration. Warn but don't
|
|
208
|
-
// throw: the daemon must keep starting, the misconfigured tenant
|
|
209
|
-
// just stays OFF.
|
|
210
|
-
warn("feature-flag: skipping tenant entry with internal whitespace", {
|
|
211
|
-
envVar: FEATURE_FLAG_TENANTS_ENV_VAR,
|
|
212
|
-
rawEntry: trimmed,
|
|
213
|
-
});
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
out.add(trimmed);
|
|
47
|
+
* Returns true only for an explicit local disable value. Values such as
|
|
48
|
+
* `true`, `1`, a tenant list, or an unrecognized string never enable sync.
|
|
49
|
+
*/
|
|
50
|
+
export function isEventSyncLocallyDisabled(
|
|
51
|
+
value: string | undefined,
|
|
52
|
+
): boolean {
|
|
53
|
+
switch ((value ?? "").trim().toLowerCase()) {
|
|
54
|
+
case "0":
|
|
55
|
+
case "false":
|
|
56
|
+
case "no":
|
|
57
|
+
case "off":
|
|
58
|
+
return true;
|
|
59
|
+
default:
|
|
60
|
+
return false;
|
|
217
61
|
}
|
|
218
|
-
return out;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Default warn destination — plain `console.warn`.
|
|
223
|
-
*/
|
|
224
|
-
function defaultWarn(
|
|
225
|
-
message: string,
|
|
226
|
-
context: Record<string, unknown>,
|
|
227
|
-
): void {
|
|
228
|
-
console.warn(message, context);
|
|
229
62
|
}
|
package/src/sync/index.ts
CHANGED
|
@@ -28,11 +28,9 @@ export type {
|
|
|
28
28
|
} from "./push-transport.js";
|
|
29
29
|
|
|
30
30
|
export {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
EnvTenantListFlagProvider,
|
|
31
|
+
EVENT_SYNC_LOCAL_DISABLE_ENV_VAR,
|
|
32
|
+
isEventSyncLocallyDisabled,
|
|
34
33
|
StaticFlagProvider,
|
|
35
|
-
defaultFlagProvider,
|
|
36
34
|
} from "./feature-flags.js";
|
|
37
35
|
export type {
|
|
38
36
|
EventDrivenPushFlagProvider,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { RealtimeLeaseClient, RealtimePassAuthorizer } from "./lease-client.js";
|
|
4
|
+
import { RealtimeRolloutClient, type RealtimeCapabilityVector } from "./realtime-rollout.js";
|
|
5
|
+
|
|
6
|
+
const capability: RealtimeCapabilityVector = {
|
|
7
|
+
hqCloud: "6.14.49",
|
|
8
|
+
hqDesktop: "none",
|
|
9
|
+
protocol: 2,
|
|
10
|
+
journal: 3,
|
|
11
|
+
durableApply: 1,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
15
|
+
return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("RealtimeLeaseClient", () => {
|
|
19
|
+
it("uses the pass bearer and rejects a lease longer than sixty seconds", async () => {
|
|
20
|
+
let received: { method?: string; headers?: Record<string, string> } | undefined;
|
|
21
|
+
const fetchImpl = async (_url: string, init?: { method?: string; headers?: Record<string, string> }) => {
|
|
22
|
+
received = init;
|
|
23
|
+
return jsonResponse({
|
|
24
|
+
lease: "lease_1",
|
|
25
|
+
issuedAt: "2026-08-12T00:00:00.000Z",
|
|
26
|
+
expiresAt: "2026-08-12T00:01:01.000Z",
|
|
27
|
+
authorizationEpoch: "epoch_1",
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
const client = new RealtimeLeaseClient({ apiUrl: "https://api.example", fetchImpl, now: () => Date.parse("2026-08-12T00:00:00.000Z") });
|
|
31
|
+
|
|
32
|
+
await expect(client.issue({ bearer: "token-a", deviceId: "machine_a", capabilityVector: capability, scopeHandle: "handle_1", scopeRevision: "rev_1" })).rejects.toThrow("invalid lease lifetime");
|
|
33
|
+
expect(received).toMatchObject({
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: expect.objectContaining({ Authorization: "Bearer token-a", "X-HQ-Sync-Contract": "2" }),
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a lease issued too far into the future", async () => {
|
|
40
|
+
const fetchImpl = async () => jsonResponse({
|
|
41
|
+
lease: "lease_1",
|
|
42
|
+
issuedAt: "2026-08-12T00:00:06.000Z",
|
|
43
|
+
expiresAt: "2026-08-12T00:01:00.000Z",
|
|
44
|
+
authorizationEpoch: "epoch_1",
|
|
45
|
+
});
|
|
46
|
+
const client = new RealtimeLeaseClient({ apiUrl: "https://api.example", fetchImpl, now: () => Date.parse("2026-08-12T00:00:00.000Z") });
|
|
47
|
+
await expect(client.issue({ bearer: "token-a", deviceId: "machine_a", capabilityVector: capability, scopeHandle: "handle_1", scopeRevision: "rev_1" })).rejects.toThrow("invalid lease lifetime");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("resolves one bearer and freshly leases every selected scope before I/O", async () => {
|
|
51
|
+
const now = Date.parse("2026-08-12T00:00:00.000Z");
|
|
52
|
+
const requests: Array<{ headers?: Record<string, string> }> = [];
|
|
53
|
+
const fetchImpl = async (url: string, init?: { headers?: Record<string, string> }) => {
|
|
54
|
+
requests.push({ headers: init?.headers });
|
|
55
|
+
if (url.endsWith("/scopes")) {
|
|
56
|
+
return jsonResponse({ scopes: [
|
|
57
|
+
{ scopeUid: "prs_1", scopeHandle: "handle_1", scopeRevision: "rev_1", state: "v2-ready", cursor: "cursor_1" },
|
|
58
|
+
{ scopeUid: "cmp_1", scopeHandle: "handle_2", scopeRevision: "rev_2", state: "v2-ready", cursor: "cursor_2" },
|
|
59
|
+
] });
|
|
60
|
+
}
|
|
61
|
+
return jsonResponse({
|
|
62
|
+
lease: "lease_issued",
|
|
63
|
+
issuedAt: "2026-08-12T00:00:00.000Z",
|
|
64
|
+
expiresAt: "2026-08-12T00:01:00.000Z",
|
|
65
|
+
authorizationEpoch: "epoch_1",
|
|
66
|
+
}, 201);
|
|
67
|
+
};
|
|
68
|
+
const token = vi.fn(async () => "token-a");
|
|
69
|
+
const inventory = new RealtimeRolloutClient({ apiUrl: "https://api.example", fetchImpl, now: () => now });
|
|
70
|
+
const leases = new RealtimeLeaseClient({ apiUrl: "https://api.example", fetchImpl, now: () => now });
|
|
71
|
+
const authorizer = new RealtimePassAuthorizer(token, inventory, leases);
|
|
72
|
+
const operation = vi.fn(async () => "done");
|
|
73
|
+
|
|
74
|
+
await expect(authorizer.runBeforeIo({
|
|
75
|
+
deviceId: "machine_a",
|
|
76
|
+
capabilityVector: capability,
|
|
77
|
+
configurationFingerprint: "config-a",
|
|
78
|
+
scopeFingerprint: "prs_1,cmp_1",
|
|
79
|
+
scopeUids: ["prs_1", "cmp_1"],
|
|
80
|
+
}, operation)).resolves.toBe("done");
|
|
81
|
+
|
|
82
|
+
expect(token).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(requests).toHaveLength(3);
|
|
84
|
+
for (const call of requests) expect(call.headers?.Authorization).toBe("Bearer token-a");
|
|
85
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not invoke I/O when inventory loses a requested scope", async () => {
|
|
89
|
+
const fetchImpl = vi.fn(async () => jsonResponse({ scopes: [
|
|
90
|
+
{ scopeUid: "prs_1", scopeHandle: "handle_1", scopeRevision: "rev_1", state: "paused", reasonCode: "maintenance" },
|
|
91
|
+
] }));
|
|
92
|
+
const inventory = new RealtimeRolloutClient({ apiUrl: "https://api.example", fetchImpl });
|
|
93
|
+
const leases = new RealtimeLeaseClient({ apiUrl: "https://api.example", fetchImpl });
|
|
94
|
+
const authorizer = new RealtimePassAuthorizer("token-a", inventory, leases);
|
|
95
|
+
const operation = vi.fn(async () => undefined);
|
|
96
|
+
|
|
97
|
+
await expect(authorizer.runBeforeIo({
|
|
98
|
+
deviceId: "machine_a", capabilityVector: capability, configurationFingerprint: "config-a", scopeFingerprint: "prs_1", scopeUids: ["prs_1"],
|
|
99
|
+
}, operation)).rejects.toThrow("does not authorize every requested scope");
|
|
100
|
+
expect(operation).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not invoke I/O when parallel lease issues disagree on epoch", async () => {
|
|
104
|
+
let leaseCalls = 0;
|
|
105
|
+
const fetchImpl = async (url: string) => {
|
|
106
|
+
if (url.endsWith("/scopes")) return jsonResponse({ scopes: [
|
|
107
|
+
{ scopeUid: "prs_1", scopeHandle: "handle_1", scopeRevision: "rev_1", state: "v2-ready", cursor: "cursor_1" },
|
|
108
|
+
{ scopeUid: "cmp_1", scopeHandle: "handle_2", scopeRevision: "rev_2", state: "v2-ready", cursor: "cursor_2" },
|
|
109
|
+
] });
|
|
110
|
+
leaseCalls += 1;
|
|
111
|
+
return jsonResponse({
|
|
112
|
+
lease: `lease_${leaseCalls}`,
|
|
113
|
+
issuedAt: "2026-08-12T00:00:00.000Z",
|
|
114
|
+
expiresAt: "2026-08-12T00:00:30.000Z",
|
|
115
|
+
authorizationEpoch: `epoch_${leaseCalls}`,
|
|
116
|
+
}, 201);
|
|
117
|
+
};
|
|
118
|
+
const now = () => Date.parse("2026-08-12T00:00:00.000Z");
|
|
119
|
+
const inventory = new RealtimeRolloutClient({ apiUrl: "https://api.example", fetchImpl, now });
|
|
120
|
+
const leases = new RealtimeLeaseClient({ apiUrl: "https://api.example", fetchImpl, now });
|
|
121
|
+
const authorizer = new RealtimePassAuthorizer("token-a", inventory, leases);
|
|
122
|
+
const operation = vi.fn(async () => undefined);
|
|
123
|
+
await expect(authorizer.runBeforeIo({
|
|
124
|
+
deviceId: "machine_a", capabilityVector: capability, configurationFingerprint: "config-a", scopeFingerprint: "all", scopeUids: ["prs_1", "cmp_1"],
|
|
125
|
+
}, operation)).rejects.toThrow("leases disagree on authorization epoch");
|
|
126
|
+
expect(operation).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { AuthTokenSource, FetchLike } from "./push-transport.js";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_REALTIME_AUTHORIZATION_TIMEOUT_MS,
|
|
4
|
+
REALTIME_SYNC_CONTRACT_VERSION,
|
|
5
|
+
type RealtimeCapabilityVector,
|
|
6
|
+
type RealtimeInventoryGrant,
|
|
7
|
+
type RealtimeInventoryRequest,
|
|
8
|
+
type RealtimeRolloutClient,
|
|
9
|
+
resolveRealtimeBearer,
|
|
10
|
+
} from "./realtime-rollout.js";
|
|
11
|
+
|
|
12
|
+
export const MAX_REALTIME_LEASE_MS = 60_000;
|
|
13
|
+
export const MAX_REALTIME_LEASE_CLOCK_SKEW_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
export interface RealtimeLease {
|
|
16
|
+
lease: string;
|
|
17
|
+
issuedAt: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
authorizationEpoch: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RealtimeLeaseRequest {
|
|
23
|
+
bearer: string;
|
|
24
|
+
deviceId: string;
|
|
25
|
+
capabilityVector: RealtimeCapabilityVector;
|
|
26
|
+
scopeHandle: string;
|
|
27
|
+
scopeRevision: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RealtimeLeaseClientOptions {
|
|
31
|
+
apiUrl: string;
|
|
32
|
+
fetchImpl?: FetchLike;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
authorizationTimeoutMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asNonEmptyString(value: unknown, field: string): string {
|
|
38
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`realtime lease: ${field} must be a non-empty string`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseLease(value: unknown, now: number): RealtimeLease {
|
|
43
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("realtime lease: response is not an object");
|
|
44
|
+
const record = value as Record<string, unknown>;
|
|
45
|
+
if (Object.keys(record).some((key) => !["lease", "issuedAt", "expiresAt", "authorizationEpoch"].includes(key))) {
|
|
46
|
+
throw new Error("realtime lease: response contains an unknown field");
|
|
47
|
+
}
|
|
48
|
+
const lease: RealtimeLease = {
|
|
49
|
+
lease: asNonEmptyString(record.lease, "lease"),
|
|
50
|
+
issuedAt: asNonEmptyString(record.issuedAt, "issuedAt"),
|
|
51
|
+
expiresAt: asNonEmptyString(record.expiresAt, "expiresAt"),
|
|
52
|
+
authorizationEpoch: asNonEmptyString(record.authorizationEpoch, "authorizationEpoch"),
|
|
53
|
+
};
|
|
54
|
+
const issuedAtMs = Date.parse(lease.issuedAt);
|
|
55
|
+
const expiresAtMs = Date.parse(lease.expiresAt);
|
|
56
|
+
if (
|
|
57
|
+
!Number.isFinite(issuedAtMs) ||
|
|
58
|
+
!Number.isFinite(expiresAtMs) ||
|
|
59
|
+
issuedAtMs > now + MAX_REALTIME_LEASE_CLOCK_SKEW_MS ||
|
|
60
|
+
expiresAtMs <= now ||
|
|
61
|
+
expiresAtMs <= issuedAtMs ||
|
|
62
|
+
expiresAtMs - issuedAtMs > MAX_REALTIME_LEASE_MS ||
|
|
63
|
+
expiresAtMs > now + MAX_REALTIME_LEASE_MS
|
|
64
|
+
) {
|
|
65
|
+
throw new Error("realtime lease: invalid lease lifetime");
|
|
66
|
+
}
|
|
67
|
+
return lease;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Issues a non-cacheable, <=60 second lease for one exact inventory grant. */
|
|
71
|
+
export class RealtimeLeaseClient {
|
|
72
|
+
private readonly apiUrl: string;
|
|
73
|
+
private readonly fetchImpl: FetchLike;
|
|
74
|
+
private readonly now: () => number;
|
|
75
|
+
private readonly authorizationTimeoutMs: number;
|
|
76
|
+
|
|
77
|
+
constructor(options: RealtimeLeaseClientOptions) {
|
|
78
|
+
if (!options.apiUrl.trim()) throw new Error("realtime lease: apiUrl is required");
|
|
79
|
+
this.apiUrl = options.apiUrl.replace(/\/+$/, "");
|
|
80
|
+
this.fetchImpl = options.fetchImpl ?? ((input, init) => (globalThis.fetch as unknown as FetchLike)(input, init));
|
|
81
|
+
this.now = options.now ?? Date.now;
|
|
82
|
+
this.authorizationTimeoutMs = options.authorizationTimeoutMs ?? DEFAULT_REALTIME_AUTHORIZATION_TIMEOUT_MS;
|
|
83
|
+
if (!Number.isSafeInteger(this.authorizationTimeoutMs) || this.authorizationTimeoutMs <= 0) {
|
|
84
|
+
throw new Error("realtime lease: authorizationTimeoutMs must be a positive integer");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
assertUsable(lease: RealtimeLease): void {
|
|
89
|
+
const expiresAtMs = Date.parse(lease.expiresAt);
|
|
90
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= this.now()) {
|
|
91
|
+
throw new Error("realtime lease: expired before guarded I/O");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async issue(request: RealtimeLeaseRequest): Promise<RealtimeLease> {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const timeout = setTimeout(() => controller.abort(), this.authorizationTimeoutMs);
|
|
98
|
+
let response: Awaited<ReturnType<FetchLike>>;
|
|
99
|
+
try {
|
|
100
|
+
response = await this.fetchImpl(`${this.apiUrl}/v2/sync/leases`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${request.bearer}`,
|
|
104
|
+
Accept: "application/json",
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
"X-HQ-Sync-Contract": String(REALTIME_SYNC_CONTRACT_VERSION),
|
|
107
|
+
"X-HQ-Device-ID": request.deviceId,
|
|
108
|
+
"X-HQ-Cloud-Version": request.capabilityVector.hqCloud,
|
|
109
|
+
"X-HQ-Desktop-Version": request.capabilityVector.hqDesktop,
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
scopeHandle: request.scopeHandle,
|
|
113
|
+
scopeRevision: request.scopeRevision,
|
|
114
|
+
capabilityVector: request.capabilityVector,
|
|
115
|
+
}),
|
|
116
|
+
signal: controller.signal,
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(`realtime lease: POST /v2/sync/leases failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
}
|
|
123
|
+
const text = await response.text().catch((error: unknown) => {
|
|
124
|
+
throw new Error(`realtime lease: unable to read response: ${error instanceof Error ? error.message : String(error)}`);
|
|
125
|
+
});
|
|
126
|
+
if (!response.ok) throw new Error(`realtime lease: POST /v2/sync/leases failed (${response.status}): ${text.slice(0, 300)}`);
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(text);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new Error(`realtime lease: response is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
132
|
+
}
|
|
133
|
+
return parseLease(parsed, this.now());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface RealtimeScopeAuthorization {
|
|
138
|
+
grant: RealtimeInventoryGrant & { state: "v2-ready" };
|
|
139
|
+
lease: RealtimeLease;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface RealtimePassAuthorization {
|
|
143
|
+
/** One resolved bearer is reused by every inventory and lease request in this pass. */
|
|
144
|
+
bearer: string;
|
|
145
|
+
scopes: readonly RealtimeScopeAuthorization[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface RealtimePassRequest {
|
|
149
|
+
deviceId: string;
|
|
150
|
+
capabilityVector: RealtimeCapabilityVector;
|
|
151
|
+
configurationFingerprint: string;
|
|
152
|
+
scopeFingerprint: string;
|
|
153
|
+
scopeUids: readonly string[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The only pre-I/O admission seam for later V2 work. It intentionally has no
|
|
158
|
+
* V1 fallback: malformed, stale, revoked, or non-ready inventory never gives
|
|
159
|
+
* an operation a lease. It resolves its bearer once, then reissues leases on
|
|
160
|
+
* every guarded pass so cached positives cannot cross server config/scope
|
|
161
|
+
* changes.
|
|
162
|
+
*/
|
|
163
|
+
export class RealtimePassAuthorizer {
|
|
164
|
+
constructor(
|
|
165
|
+
private readonly authToken: AuthTokenSource,
|
|
166
|
+
private readonly inventoryClient: RealtimeRolloutClient,
|
|
167
|
+
private readonly leaseClient: RealtimeLeaseClient,
|
|
168
|
+
) {}
|
|
169
|
+
|
|
170
|
+
async authorize(request: RealtimePassRequest): Promise<RealtimePassAuthorization> {
|
|
171
|
+
const bearer = await resolveRealtimeBearer(this.authToken);
|
|
172
|
+
const inventoryRequest: RealtimeInventoryRequest = {
|
|
173
|
+
bearer,
|
|
174
|
+
deviceId: request.deviceId,
|
|
175
|
+
capabilityVector: request.capabilityVector,
|
|
176
|
+
configurationFingerprint: request.configurationFingerprint,
|
|
177
|
+
scopeFingerprint: request.scopeFingerprint,
|
|
178
|
+
};
|
|
179
|
+
const inventory = await this.inventoryClient.inventory(inventoryRequest);
|
|
180
|
+
const requested = new Set(request.scopeUids);
|
|
181
|
+
if (requested.size === 0) throw new Error("realtime authorization: guarded pass has no scopes");
|
|
182
|
+
const grants = inventory.scopes.filter((grant) => requested.has(grant.scopeUid));
|
|
183
|
+
if (grants.length !== requested.size || grants.some((grant) => grant.state !== "v2-ready")) {
|
|
184
|
+
throw new Error("realtime authorization: inventory does not authorize every requested scope");
|
|
185
|
+
}
|
|
186
|
+
const scopes = await Promise.all(grants.map(async (grant) => ({
|
|
187
|
+
grant: grant as RealtimeInventoryGrant & { state: "v2-ready" },
|
|
188
|
+
lease: await this.leaseClient.issue({
|
|
189
|
+
bearer,
|
|
190
|
+
deviceId: request.deviceId,
|
|
191
|
+
capabilityVector: request.capabilityVector,
|
|
192
|
+
scopeHandle: grant.scopeHandle,
|
|
193
|
+
scopeRevision: grant.scopeRevision,
|
|
194
|
+
}),
|
|
195
|
+
})));
|
|
196
|
+
const authorizationEpoch = scopes[0]?.lease.authorizationEpoch;
|
|
197
|
+
if (!authorizationEpoch || scopes.some((scope) => scope.lease.authorizationEpoch !== authorizationEpoch)) {
|
|
198
|
+
throw new Error("realtime authorization: leases disagree on authorization epoch");
|
|
199
|
+
}
|
|
200
|
+
for (const scope of scopes) this.leaseClient.assertUsable(scope.lease);
|
|
201
|
+
return { bearer, scopes };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async runBeforeIo<T>(request: RealtimePassRequest, operation: (authorization: RealtimePassAuthorization) => Promise<T>): Promise<T> {
|
|
205
|
+
return operation(await this.authorize(request));
|
|
206
|
+
}
|
|
207
|
+
}
|