@indigoai-us/hq-cloud 6.14.24 → 6.14.26
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/dist/cli/sync-scope.test.js +87 -0
- package/dist/cli/sync-scope.test.js.map +1 -1
- package/dist/cli/sync.d.ts +13 -7
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +39 -16
- package/dist/cli/sync.js.map +1 -1
- package/dist/company-resolver.d.ts +31 -1
- package/dist/company-resolver.d.ts.map +1 -1
- package/dist/company-resolver.js +329 -21
- package/dist/company-resolver.js.map +1 -1
- package/dist/company-resolver.test.js +371 -1
- package/dist/company-resolver.test.js.map +1 -1
- package/dist/skill-telemetry.js +1 -1
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/telemetry.d.ts +10 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +95 -4
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +177 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/vault-client.d.ts +42 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +40 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/sync-scope.test.ts +96 -0
- package/src/cli/sync.ts +81 -31
- package/src/company-resolver.test.ts +432 -1
- package/src/company-resolver.ts +362 -23
- package/src/skill-telemetry.ts +1 -1
- package/src/telemetry.test.ts +241 -0
- package/src/telemetry.ts +124 -6
- package/src/vault-client.ts +68 -0
package/src/telemetry.test.ts
CHANGED
|
@@ -33,24 +33,42 @@ interface StubClient extends TelemetryClientSurface {
|
|
|
33
33
|
posts: UsageBatch[];
|
|
34
34
|
/** Number of times `getTelemetryOptIn` was called. */
|
|
35
35
|
optInCalls: number;
|
|
36
|
+
/** Every value handed to `setTelemetryOptIn`, in arrival order. */
|
|
37
|
+
optInSets: boolean[];
|
|
38
|
+
/** The options object passed alongside each `setTelemetryOptIn` call. */
|
|
39
|
+
optInSetOpts: Array<{ onlyIfUnset?: boolean } | undefined>;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
function makeClient(opts: {
|
|
39
43
|
optInResponse?: TelemetryOptInResponse | Error;
|
|
40
44
|
postResponse?: UsageIngestResult | Error;
|
|
45
|
+
/** When set, `setTelemetryOptIn` rejects with this error. */
|
|
46
|
+
setOptInError?: Error;
|
|
47
|
+
/** `applied` value the conditional write reports back (default true). */
|
|
48
|
+
setOptInApplied?: boolean;
|
|
41
49
|
} = {}): StubClient {
|
|
42
50
|
const optInResponse = opts.optInResponse ?? { enabled: true, updatedAt: null };
|
|
43
51
|
const postResponse = opts.postResponse ?? { ok: true, written: 0, skipped: [] };
|
|
44
52
|
|
|
45
53
|
const posts: UsageBatch[] = [];
|
|
54
|
+
const optInSets: boolean[] = [];
|
|
55
|
+
const optInSetOpts: Array<{ onlyIfUnset?: boolean } | undefined> = [];
|
|
46
56
|
const stub: StubClient = {
|
|
47
57
|
posts,
|
|
48
58
|
optInCalls: 0,
|
|
59
|
+
optInSets,
|
|
60
|
+
optInSetOpts,
|
|
49
61
|
async getTelemetryOptIn() {
|
|
50
62
|
this.optInCalls++;
|
|
51
63
|
if (optInResponse instanceof Error) throw optInResponse;
|
|
52
64
|
return optInResponse;
|
|
53
65
|
},
|
|
66
|
+
async setTelemetryOptIn(enabled: boolean, setOpts?: { onlyIfUnset?: boolean }) {
|
|
67
|
+
optInSets.push(enabled);
|
|
68
|
+
optInSetOpts.push(setOpts);
|
|
69
|
+
if (opts.setOptInError) throw opts.setOptInError;
|
|
70
|
+
return { applied: opts.setOptInApplied ?? true };
|
|
71
|
+
},
|
|
54
72
|
async postUsage(batch: UsageBatch) {
|
|
55
73
|
posts.push(batch);
|
|
56
74
|
if (postResponse instanceof Error) throw postResponse;
|
|
@@ -722,3 +740,226 @@ describe("collectAndSendTelemetry — companyUid attribution", () => {
|
|
|
722
740
|
for (const ev of events) expect(ev.companyUid).toBe(COMPANY_UID);
|
|
723
741
|
});
|
|
724
742
|
});
|
|
743
|
+
|
|
744
|
+
const CALLER = "prs_01CALLER";
|
|
745
|
+
|
|
746
|
+
// ── Consent self-heal ────────────────────────────────────────────────────────
|
|
747
|
+
//
|
|
748
|
+
// Regression for the opt-in race: the installer writes the user's answer to
|
|
749
|
+
// `~/.hq/menubar.json` (always succeeds) AND posts it to `/v1/usage/opt-in` —
|
|
750
|
+
// but that post fires before the person entity exists, so it 404s and the
|
|
751
|
+
// server attribute is never written. Absence read as `false`, the emitter went
|
|
752
|
+
// silent, and the person showed as "not opted in" forever. Measured in prod:
|
|
753
|
+
// 22 of 33 active Indigo members had NO attribute at all, against 2 genuine
|
|
754
|
+
// opt-outs.
|
|
755
|
+
//
|
|
756
|
+
// The heal replays the answer the user already gave. It must fire ONLY on the
|
|
757
|
+
// server's `unset` state, and must never invent or overwrite consent.
|
|
758
|
+
|
|
759
|
+
describe("collectAndSendTelemetry — consent self-heal", () => {
|
|
760
|
+
let env: TestEnv;
|
|
761
|
+
beforeEach(() => {
|
|
762
|
+
env = setupEnv();
|
|
763
|
+
});
|
|
764
|
+
afterEach(() => teardownEnv(env));
|
|
765
|
+
|
|
766
|
+
// The cached answer must name the account it belongs to; an unbound record is
|
|
767
|
+
// deliberately not replayable (see the cross-account test below).
|
|
768
|
+
function writeMenubar(value: unknown, personUid: string | undefined = CALLER): void {
|
|
769
|
+
fs.writeFileSync(
|
|
770
|
+
env.menubarPath,
|
|
771
|
+
JSON.stringify({
|
|
772
|
+
telemetryEnabled: value,
|
|
773
|
+
...(personUid ? { telemetryOptInPersonUid: personUid } : {}),
|
|
774
|
+
}),
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
it("re-asserts a local opt-IN when the server has no recorded consent", async () => {
|
|
779
|
+
writeMenubar(true);
|
|
780
|
+
const client = makeClient({
|
|
781
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
785
|
+
|
|
786
|
+
expect(client.optInSets).toEqual([true]);
|
|
787
|
+
expect(result.enabled).toBe(true);
|
|
788
|
+
expect(result.optInSource).toBe("menubar-reasserted");
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("re-asserts a local opt-OUT so the server records the real answer", async () => {
|
|
792
|
+
writeMenubar(false);
|
|
793
|
+
const client = makeClient({
|
|
794
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
798
|
+
|
|
799
|
+
expect(client.optInSets).toEqual([false]);
|
|
800
|
+
expect(result.enabled).toBe(false);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("NEVER overwrites an explicit server opt-out", async () => {
|
|
804
|
+
// The user opted in on this machine at some point, then explicitly opted
|
|
805
|
+
// out account-wide. The account-wide answer must win.
|
|
806
|
+
writeMenubar(true);
|
|
807
|
+
const client = makeClient({
|
|
808
|
+
optInResponse: { enabled: false, updatedAt: "2026-06-19T16:11:09.939Z", unset: false },
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
812
|
+
|
|
813
|
+
expect(client.optInSets).toEqual([]);
|
|
814
|
+
expect(result.enabled).toBe(false);
|
|
815
|
+
expect(result.optInSource).toBe("server");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("does not invent consent when no local answer exists", async () => {
|
|
819
|
+
// No menubar.json at all — we hold no answer, so we assert nothing.
|
|
820
|
+
const client = makeClient({
|
|
821
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
825
|
+
|
|
826
|
+
expect(client.optInSets).toEqual([]);
|
|
827
|
+
expect(result.enabled).toBe(false);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it("does not invent consent when the local value is not a boolean", async () => {
|
|
831
|
+
writeMenubar("yes");
|
|
832
|
+
const client = makeClient({
|
|
833
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
837
|
+
|
|
838
|
+
expect(client.optInSets).toEqual([]);
|
|
839
|
+
expect(result.enabled).toBe(false);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("falls back to the server answer when the re-assert POST fails", async () => {
|
|
843
|
+
writeMenubar(true);
|
|
844
|
+
const client = makeClient({
|
|
845
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
846
|
+
setOptInError: new Error("network down"),
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
850
|
+
|
|
851
|
+
expect(client.optInSets).toEqual([true]);
|
|
852
|
+
// Non-fatal: the run completes on the server's answer and retries next time.
|
|
853
|
+
expect(result.enabled).toBe(false);
|
|
854
|
+
expect(result.optInSource).toBe("server");
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("skips the heal entirely against an older server that omits `unset`", async () => {
|
|
858
|
+
writeMenubar(true);
|
|
859
|
+
const client = makeClient({ optInResponse: { enabled: false, updatedAt: null } });
|
|
860
|
+
|
|
861
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
862
|
+
|
|
863
|
+
expect(client.optInSets).toEqual([]);
|
|
864
|
+
expect(result.optInSource).toBe("server");
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// The self-heal must be ATOMIC. Reading `unset` and replaying the local answer
|
|
869
|
+
// are two requests; without a server-side condition another device could record
|
|
870
|
+
// a real opt-out in between and the replay would clobber it.
|
|
871
|
+
describe("collectAndSendTelemetry — self-heal is conditional", () => {
|
|
872
|
+
let env: TestEnv;
|
|
873
|
+
beforeEach(() => {
|
|
874
|
+
env = setupEnv();
|
|
875
|
+
});
|
|
876
|
+
afterEach(() => teardownEnv(env));
|
|
877
|
+
|
|
878
|
+
it("asks the server to write ONLY if the consent is still unset", async () => {
|
|
879
|
+
fs.writeFileSync(
|
|
880
|
+
env.menubarPath,
|
|
881
|
+
JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: CALLER }),
|
|
882
|
+
);
|
|
883
|
+
const client = makeClient({
|
|
884
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
await collectAndSendTelemetry(makeOpts(env, client));
|
|
888
|
+
|
|
889
|
+
expect(client.optInSetOpts[0]).toMatchObject({ onlyIfUnset: true });
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("defers to the server when the conditional write loses the race", async () => {
|
|
893
|
+
// A real answer was recorded elsewhere between our GET and our POST.
|
|
894
|
+
fs.writeFileSync(
|
|
895
|
+
env.menubarPath,
|
|
896
|
+
JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: CALLER }),
|
|
897
|
+
);
|
|
898
|
+
const client = makeClient({
|
|
899
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
900
|
+
setOptInApplied: false,
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
904
|
+
|
|
905
|
+
// The stale local `true` must NOT win over the answer that landed first.
|
|
906
|
+
expect(result.enabled).toBe(false);
|
|
907
|
+
expect(result.optInSource).toBe("server");
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// The cached consent lives in a per-MACHINE file. If two people sign in under
|
|
912
|
+
// the same OS user it holds whoever answered LAST, so replaying it for a
|
|
913
|
+
// different account would opt in someone who never consented.
|
|
914
|
+
describe("collectAndSendTelemetry — consent replay is account-scoped", () => {
|
|
915
|
+
let env: TestEnv;
|
|
916
|
+
beforeEach(() => {
|
|
917
|
+
env = setupEnv();
|
|
918
|
+
});
|
|
919
|
+
afterEach(() => teardownEnv(env));
|
|
920
|
+
|
|
921
|
+
it("does NOT replay a cached answer belonging to a different account", async () => {
|
|
922
|
+
fs.writeFileSync(
|
|
923
|
+
env.menubarPath,
|
|
924
|
+
JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: "prs_01SOMEONE_ELSE" }),
|
|
925
|
+
);
|
|
926
|
+
const client = makeClient({
|
|
927
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
931
|
+
|
|
932
|
+
expect(client.optInSets).toEqual([]);
|
|
933
|
+
expect(result.enabled).toBe(false);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("does NOT replay a legacy record with no account binding", async () => {
|
|
937
|
+
// Written before consent was bound to an account — we cannot prove whose
|
|
938
|
+
// answer it is, so the installer's post-sign-in upload is the recovery path.
|
|
939
|
+
fs.writeFileSync(env.menubarPath, JSON.stringify({ telemetryEnabled: true }));
|
|
940
|
+
const client = makeClient({
|
|
941
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true, personUid: CALLER },
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
945
|
+
|
|
946
|
+
expect(client.optInSets).toEqual([]);
|
|
947
|
+
expect(result.enabled).toBe(false);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("does NOT replay when the server cannot say who the caller is", async () => {
|
|
951
|
+
fs.writeFileSync(
|
|
952
|
+
env.menubarPath,
|
|
953
|
+
JSON.stringify({ telemetryEnabled: true, telemetryOptInPersonUid: CALLER }),
|
|
954
|
+
);
|
|
955
|
+
// Older server: no `personUid`, so the match cannot be proven.
|
|
956
|
+
const client = makeClient({
|
|
957
|
+
optInResponse: { enabled: false, updatedAt: null, unset: true },
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
961
|
+
|
|
962
|
+
expect(client.optInSets).toEqual([]);
|
|
963
|
+
expect(result.enabled).toBe(false);
|
|
964
|
+
});
|
|
965
|
+
});
|
package/src/telemetry.ts
CHANGED
|
@@ -48,6 +48,14 @@ import type {
|
|
|
48
48
|
export interface TelemetryClientSurface {
|
|
49
49
|
getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
|
|
50
50
|
postUsage(batch: UsageBatch): Promise<UsageIngestResult>;
|
|
51
|
+
/**
|
|
52
|
+
* Optional so an older client (or a narrow test stub) still satisfies the
|
|
53
|
+
* surface — when it is absent the consent self-heal is simply skipped.
|
|
54
|
+
*/
|
|
55
|
+
setTelemetryOptIn?(
|
|
56
|
+
enabled: boolean,
|
|
57
|
+
opts?: { onlyIfUnset?: boolean },
|
|
58
|
+
): Promise<{ applied: boolean } | void>;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
export interface CollectTelemetryOptions {
|
|
@@ -79,7 +87,7 @@ export interface CollectTelemetryResult {
|
|
|
79
87
|
/** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, nothing else ran. */
|
|
80
88
|
enabled: boolean;
|
|
81
89
|
/** Source for the `enabled` decision — useful for diagnosing missing-events reports. */
|
|
82
|
-
optInSource: "server" | "menubar-fallback" | "skipped";
|
|
90
|
+
optInSource: "server" | "menubar-fallback" | "menubar-reasserted" | "skipped";
|
|
83
91
|
/** How many `.jsonl` files we considered (before the cursor diff). */
|
|
84
92
|
filesScanned: number;
|
|
85
93
|
/** Total events successfully POSTed across all batches. */
|
|
@@ -129,12 +137,58 @@ async function saveCursor(cursorPath: string, cursor: TelemetryCursor): Promise<
|
|
|
129
137
|
// ── Local opt-in fallback ─────────────────────────────────────────────────────
|
|
130
138
|
|
|
131
139
|
async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
|
|
140
|
+
return (await readLocalTelemetryPreference(menubarPath)) === true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Tri-state read of the locally-stored consent.
|
|
145
|
+
*
|
|
146
|
+
* `~/.hq/menubar.json` → `telemetryEnabled` is written by the installer the
|
|
147
|
+
* moment the user answers the prompt, and that local write ALWAYS succeeds —
|
|
148
|
+
* unlike the paired server write, which fires before the person entity exists
|
|
149
|
+
* and 404s. So this file is frequently the ONLY durable record of the user's
|
|
150
|
+
* actual choice.
|
|
151
|
+
*
|
|
152
|
+
* `undefined` means absent / unreadable / not a boolean — "we hold no answer" —
|
|
153
|
+
* as distinct from `false`, which is a real opt-out. The self-heal path must
|
|
154
|
+
* never conflate the two: it replays an answer, it does not invent one.
|
|
155
|
+
*/
|
|
156
|
+
async function readLocalTelemetryPreference(
|
|
157
|
+
menubarPath: string,
|
|
158
|
+
): Promise<boolean | undefined> {
|
|
159
|
+
return (await readLocalConsentRecord(menubarPath)).enabled;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The locally-cached consent plus the account it belongs to.
|
|
164
|
+
*
|
|
165
|
+
* `telemetryOptInPersonUid` binds the answer to the `prs_*` that gave it.
|
|
166
|
+
* `menubar.json` is a per-MACHINE file, so when two people sign in under the
|
|
167
|
+
* same OS user it holds whoever answered LAST — replaying it unconditionally
|
|
168
|
+
* would opt in an account that never consented. The binding is what makes the
|
|
169
|
+
* replay safe; an unbound (legacy) record cannot be proven to belong to the
|
|
170
|
+
* current caller and is therefore never replayed.
|
|
171
|
+
*/
|
|
172
|
+
async function readLocalConsentRecord(
|
|
173
|
+
menubarPath: string,
|
|
174
|
+
): Promise<{ enabled?: boolean; personUid?: string }> {
|
|
132
175
|
try {
|
|
133
176
|
const raw = await fs.readFile(menubarPath, "utf-8");
|
|
134
|
-
const parsed = JSON.parse(raw) as {
|
|
135
|
-
|
|
177
|
+
const parsed = JSON.parse(raw) as {
|
|
178
|
+
telemetryEnabled?: unknown;
|
|
179
|
+
telemetryOptInPersonUid?: unknown;
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
enabled:
|
|
183
|
+
typeof parsed.telemetryEnabled === "boolean" ? parsed.telemetryEnabled : undefined,
|
|
184
|
+
personUid:
|
|
185
|
+
typeof parsed.telemetryOptInPersonUid === "string" &&
|
|
186
|
+
parsed.telemetryOptInPersonUid.length > 0
|
|
187
|
+
? parsed.telemetryOptInPersonUid
|
|
188
|
+
: undefined,
|
|
189
|
+
};
|
|
136
190
|
} catch {
|
|
137
|
-
return
|
|
191
|
+
return {};
|
|
138
192
|
}
|
|
139
193
|
}
|
|
140
194
|
|
|
@@ -422,15 +476,79 @@ export async function collectAndSendTelemetry(
|
|
|
422
476
|
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
423
477
|
const repoCompanyMap: RepoCompanyMap = opts.hqRoot
|
|
424
478
|
? await buildRepoCompanyMap(opts.hqRoot)
|
|
425
|
-
: { entries: [], bySlug: new Map() };
|
|
479
|
+
: { entries: [], bySlug: new Map(), foldsCase: false, ambiguous: new Set<string>() };
|
|
426
480
|
|
|
427
|
-
// 1. Opt-in check (server-authoritative, with local fallback).
|
|
481
|
+
// 1. Opt-in check (server-authoritative, with local fallback + self-heal).
|
|
428
482
|
let enabled: boolean;
|
|
429
483
|
let optInSource: CollectTelemetryResult["optInSource"];
|
|
430
484
|
try {
|
|
431
485
|
const resp = await opts.client.getTelemetryOptIn();
|
|
432
486
|
enabled = resp.enabled === true;
|
|
433
487
|
optInSource = "server";
|
|
488
|
+
|
|
489
|
+
// Self-heal a consent the user gave but that never reached the server.
|
|
490
|
+
//
|
|
491
|
+
// The installer writes the answer to `~/.hq/menubar.json` (always succeeds)
|
|
492
|
+
// AND posts it to `/v1/usage/opt-in` — but that post fires before the
|
|
493
|
+
// person entity exists, so it 404s and the attribute is never written.
|
|
494
|
+
// Absence then reads as `false`, the emitter goes silent, and the person
|
|
495
|
+
// shows as "not opted in" forever. Measured: 22 of 33 active Indigo members
|
|
496
|
+
// had no attribute at all, against only 2 genuine opt-outs.
|
|
497
|
+
//
|
|
498
|
+
// `unset` (never answered) is the ONLY state we heal. An explicit server
|
|
499
|
+
// `false` is a real opt-out and is left strictly alone. We also require a
|
|
500
|
+
// local answer to actually exist — we never invent consent, we only replay
|
|
501
|
+
// the answer the user already gave.
|
|
502
|
+
//
|
|
503
|
+
// And it must be THIS account's answer. `menubar.json` is per-MACHINE, so
|
|
504
|
+
// when two people sign in under the same OS user it holds whoever answered
|
|
505
|
+
// last; replaying that for the second account would opt in someone who
|
|
506
|
+
// never consented. The replay therefore requires the cached record to name
|
|
507
|
+
// the same `prs_*` the server says we are. A legacy record with no binding
|
|
508
|
+
// cannot be proven to belong to this caller and is skipped — for those
|
|
509
|
+
// machines the installer's own post-sign-in upload is the recovery path,
|
|
510
|
+
// and it is correctly account-scoped because it runs right after that
|
|
511
|
+
// person authenticates.
|
|
512
|
+
if (resp.unset === true && opts.client.setTelemetryOptIn) {
|
|
513
|
+
const record = await readLocalConsentRecord(menubarPath);
|
|
514
|
+
const local = record.enabled;
|
|
515
|
+
const boundToCaller =
|
|
516
|
+
record.personUid !== undefined &&
|
|
517
|
+
resp.personUid !== undefined &&
|
|
518
|
+
record.personUid === resp.personUid;
|
|
519
|
+
if (local !== undefined && !boundToCaller) {
|
|
520
|
+
log(
|
|
521
|
+
"[telemetry] skipping consent re-assert: the locally cached answer is not bound to the signed-in account",
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (local !== undefined && boundToCaller) {
|
|
525
|
+
try {
|
|
526
|
+
// `onlyIfUnset` keeps the replay atomic. Reading `unset` and writing
|
|
527
|
+
// are two requests, so without it another device could record a real
|
|
528
|
+
// opt-out in between and this stale local answer would overwrite it.
|
|
529
|
+
// The server tests "still unset?" as part of the write instead.
|
|
530
|
+
const ack = await opts.client.setTelemetryOptIn(local, { onlyIfUnset: true });
|
|
531
|
+
if (ack && ack.applied === false) {
|
|
532
|
+
// Lost the race — a real answer was recorded first and it stands.
|
|
533
|
+
// Keep the server's answer for this run rather than the local one.
|
|
534
|
+
log(
|
|
535
|
+
"[telemetry] consent was recorded elsewhere before the re-assert landed; deferring to the server",
|
|
536
|
+
);
|
|
537
|
+
} else {
|
|
538
|
+
enabled = local;
|
|
539
|
+
optInSource = "menubar-reasserted";
|
|
540
|
+
log(
|
|
541
|
+
`[telemetry] server had no recorded consent; re-asserted the local install-time answer (enabled=${local})`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
} catch (err) {
|
|
545
|
+
// Non-fatal: fall through on the server's answer. Next run retries.
|
|
546
|
+
log(
|
|
547
|
+
`[telemetry] failed to re-assert local opt-in (${(err as Error).message ?? err})`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
434
552
|
} catch (err) {
|
|
435
553
|
log(`[telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
|
|
436
554
|
enabled = await readLocalTelemetryEnabled(menubarPath);
|
package/src/vault-client.ts
CHANGED
|
@@ -414,6 +414,25 @@ export interface VendChildResult {
|
|
|
414
414
|
export interface TelemetryOptInResponse {
|
|
415
415
|
enabled: boolean;
|
|
416
416
|
updatedAt: string | null;
|
|
417
|
+
/**
|
|
418
|
+
* `true` when the person row carries NO `telemetryOptIn` attribute — i.e. the
|
|
419
|
+
* consent question has never been answered server-side. Distinct from
|
|
420
|
+
* `enabled: false`, which is a deliberate opt-OUT.
|
|
421
|
+
*
|
|
422
|
+
* Optional because older servers omit it entirely. Absent is treated as
|
|
423
|
+
* `false`, so a client talking to one behaves exactly as before — no
|
|
424
|
+
* self-heal, no surprise writes.
|
|
425
|
+
*/
|
|
426
|
+
unset?: boolean;
|
|
427
|
+
/**
|
|
428
|
+
* The `prs_*` uid this answer belongs to — i.e. the authenticated caller.
|
|
429
|
+
*
|
|
430
|
+
* Needed because the local consent cache is a per-MACHINE file: if two people
|
|
431
|
+
* sign in under the same OS user it holds whoever answered last. A client
|
|
432
|
+
* must not replay it for a different account. Optional (older servers omit
|
|
433
|
+
* it), and absence means the replay cannot be proven safe, so it is skipped.
|
|
434
|
+
*/
|
|
435
|
+
personUid?: string;
|
|
417
436
|
}
|
|
418
437
|
|
|
419
438
|
export interface UsageBatch {
|
|
@@ -637,9 +656,26 @@ const telemetryOptInResponseSchema: VaultResponseSchema<TelemetryOptInResponse>
|
|
|
637
656
|
.object({
|
|
638
657
|
enabled: z.boolean().default(false),
|
|
639
658
|
updatedAt: z.string().nullable().default(null),
|
|
659
|
+
personUid: z.string().optional(),
|
|
660
|
+
// `.optional()`, NOT `.default(false)`: an older server omits this field,
|
|
661
|
+
// and materializing it would change the parsed response shape for every
|
|
662
|
+
// legacy caller. Callers test `unset === true`, so absent reads as
|
|
663
|
+
// "not unset" and the self-heal path is skipped, which is the pre-existing
|
|
664
|
+
// behaviour exactly.
|
|
665
|
+
unset: z.boolean().optional(),
|
|
640
666
|
})
|
|
641
667
|
.strip();
|
|
642
668
|
|
|
669
|
+
/**
|
|
670
|
+
* `POST /v1/usage/opt-in` acknowledgement.
|
|
671
|
+
*
|
|
672
|
+
* `applied` is absent on older servers (which always wrote unconditionally), so
|
|
673
|
+
* it stays optional and callers treat absence as "the write landed".
|
|
674
|
+
*/
|
|
675
|
+
const telemetryOptInAckSchema: VaultResponseSchema<{ ok: boolean; applied?: boolean }> = z
|
|
676
|
+
.object({ ok: z.boolean().default(true), applied: z.boolean().optional() })
|
|
677
|
+
.strip();
|
|
678
|
+
|
|
643
679
|
const usageIngestResultSchema: VaultResponseSchema<UsageIngestResult> = z
|
|
644
680
|
.object({
|
|
645
681
|
ok: z.boolean(),
|
|
@@ -1353,6 +1389,38 @@ export class VaultClient {
|
|
|
1353
1389
|
return this.get("/v1/usage/opt-in", telemetryOptInResponseSchema);
|
|
1354
1390
|
}
|
|
1355
1391
|
|
|
1392
|
+
/**
|
|
1393
|
+
* `POST /v1/usage/opt-in` — record the authenticated caller's consent.
|
|
1394
|
+
*
|
|
1395
|
+
* The installer is the primary writer (it owns the consent prompt). This
|
|
1396
|
+
* client-side setter exists so the sync runner can RE-ASSERT a consent the
|
|
1397
|
+
* user already gave locally but which never reached the server: the
|
|
1398
|
+
* installer's write fires before the person entity exists and 404s on
|
|
1399
|
+
* `no-person-entity`, so the answer survives only in `~/.hq/menubar.json`.
|
|
1400
|
+
* See `./telemetry.ts::collectAndSendTelemetry`, which calls this ONLY when
|
|
1401
|
+
* the server reports `unset` — never over an explicit opt-out.
|
|
1402
|
+
*
|
|
1403
|
+
* `onlyIfUnset` makes the server write conditional on the consent still never
|
|
1404
|
+
* having been recorded. The self-heal MUST pass it: reading `unset` and
|
|
1405
|
+
* replaying the answer are two separate requests, so without the condition
|
|
1406
|
+
* another device could record a real opt-out in between and this replay would
|
|
1407
|
+
* silently overwrite it. A deliberate user choice omits the flag so it always
|
|
1408
|
+
* wins. The response's `applied` reports whether the write landed.
|
|
1409
|
+
*/
|
|
1410
|
+
async setTelemetryOptIn(
|
|
1411
|
+
enabled: boolean,
|
|
1412
|
+
opts?: { onlyIfUnset?: boolean },
|
|
1413
|
+
): Promise<{ applied: boolean }> {
|
|
1414
|
+
const ack = await this.post(
|
|
1415
|
+
"/v1/usage/opt-in",
|
|
1416
|
+
opts?.onlyIfUnset ? { enabled, onlyIfUnset: true } : { enabled },
|
|
1417
|
+
telemetryOptInAckSchema,
|
|
1418
|
+
);
|
|
1419
|
+
// Older servers answer `{ ok: true }` with no `applied`; they also write
|
|
1420
|
+
// unconditionally, so a 2xx there means the write did land.
|
|
1421
|
+
return { applied: ack.applied ?? true };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1356
1424
|
/**
|
|
1357
1425
|
* `POST /v1/usage` — upload a batch of sanitized telemetry events.
|
|
1358
1426
|
*
|