@indigoai-us/hq-cloud 5.17.0 → 5.19.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/ci.yml +19 -0
- package/.github/workflows/publish.yml +53 -0
- package/dist/cli/invite.js +4 -1
- package/dist/cli/invite.js.map +1 -1
- package/dist/cli/invite.test.js +3 -0
- package/dist/cli/invite.test.js.map +1 -1
- package/dist/cli/promote.js +3 -0
- package/dist/cli/promote.js.map +1 -1
- package/dist/cli/share.d.ts +7 -5
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +189 -18
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +304 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +98 -17
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +314 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +107 -18
- package/dist/context.js.map +1 -1
- package/dist/context.test.js +63 -14
- package/dist/context.test.js.map +1 -1
- package/dist/journal.d.ts +26 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +31 -0
- package/dist/journal.js.map +1 -1
- package/dist/s3.d.ts +91 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +245 -0
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +347 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/vault-client.d.ts +24 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +29 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/invite.test.ts +3 -0
- package/src/cli/invite.ts +4 -1
- package/src/cli/promote.ts +3 -0
- package/src/cli/share.test.ts +377 -3
- package/src/cli/share.ts +241 -28
- package/src/cli/sync.test.ts +357 -0
- package/src/cli/sync.ts +133 -24
- package/src/context.test.ts +73 -14
- package/src/context.ts +116 -20
- package/src/journal.ts +33 -0
- package/src/s3.test.ts +415 -1
- package/src/s3.ts +271 -0
- package/src/vault-client.ts +37 -0
- package/tsconfig.json +12 -1
package/src/context.test.ts
CHANGED
|
@@ -47,6 +47,31 @@ function setupFetchMock(overrides?: {
|
|
|
47
47
|
fetchMock.mockImplementation(async (url: string) => {
|
|
48
48
|
const urlStr = String(url);
|
|
49
49
|
|
|
50
|
+
// New per-user-namespace slug resolver (hq-pro PR 67). Maps slug
|
|
51
|
+
// lookups to `{available: false, conflictingCompanyUid}` so the
|
|
52
|
+
// caller follows up with `/entity/{uid}`, which lands in the
|
|
53
|
+
// `/entity/cmp_` branch below — that's where `entityBody` and
|
|
54
|
+
// `entityStatus` overrides apply. The check-slug branch only
|
|
55
|
+
// honors `entityStatus` (so tests can simulate a 404/500 on the
|
|
56
|
+
// namespace lookup itself); its response shape stays fixed.
|
|
57
|
+
if (urlStr.includes("/entity/check-slug/me")) {
|
|
58
|
+
return {
|
|
59
|
+
ok: (overrides?.entityStatus ?? 200) < 400,
|
|
60
|
+
status: overrides?.entityStatus ?? 200,
|
|
61
|
+
json: async () => ({
|
|
62
|
+
available: false,
|
|
63
|
+
conflictingCompanyUid: mockEntity.uid,
|
|
64
|
+
}),
|
|
65
|
+
text: async () =>
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
available: false,
|
|
68
|
+
conflictingCompanyUid: mockEntity.uid,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Kept for any tests that still mock the legacy global endpoint
|
|
74
|
+
// directly (none should, post-PR 67 — but harmless if invoked).
|
|
50
75
|
if (urlStr.includes("/entity/by-slug/")) {
|
|
51
76
|
return {
|
|
52
77
|
ok: (overrides?.entityStatus ?? 200) < 400,
|
|
@@ -97,10 +122,16 @@ describe("resolveEntityContext", () => {
|
|
|
97
122
|
expect(ctx.credentials.accessKeyId).toBe("ASIA_TEST_KEY");
|
|
98
123
|
expect(ctx.region).toBe("us-east-1");
|
|
99
124
|
|
|
100
|
-
// Verify entity lookup used
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
expect(String(fetchMock.mock.calls[
|
|
125
|
+
// Verify entity lookup used the new per-user-namespace endpoint
|
|
126
|
+
// (PR 67) + a follow-up `/entity/{uid}` materialization + STS.
|
|
127
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
128
|
+
expect(String(fetchMock.mock.calls[0][0])).toContain(
|
|
129
|
+
"/entity/check-slug/me?type=company&slug=acme",
|
|
130
|
+
);
|
|
131
|
+
expect(String(fetchMock.mock.calls[1][0])).toContain(
|
|
132
|
+
`/entity/${mockEntity.uid}`,
|
|
133
|
+
);
|
|
134
|
+
expect(String(fetchMock.mock.calls[2][0])).toContain("/sts/vend");
|
|
104
135
|
});
|
|
105
136
|
|
|
106
137
|
it("resolves context by UID directly", async () => {
|
|
@@ -120,7 +151,9 @@ describe("resolveEntityContext", () => {
|
|
|
120
151
|
const ctx2 = await resolveEntityContext("acme", mockConfig);
|
|
121
152
|
|
|
122
153
|
expect(ctx1).toBe(ctx2); // Same reference
|
|
123
|
-
|
|
154
|
+
// 3 fetches per resolve under the new model: check-slug + entity.get + sts/vend.
|
|
155
|
+
// 1 resolve here (second call hits cache, no new fetches).
|
|
156
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
124
157
|
});
|
|
125
158
|
|
|
126
159
|
it("auto-refreshes when credentials expire soon", async () => {
|
|
@@ -137,7 +170,8 @@ describe("resolveEntityContext", () => {
|
|
|
137
170
|
// Second call should refresh because <2 min remaining
|
|
138
171
|
const ctx2 = await resolveEntityContext("acme", mockConfig);
|
|
139
172
|
expect(ctx2).not.toBe(ctx1);
|
|
140
|
-
|
|
173
|
+
// 2 resolves × 3 fetches each = 6 under the new model.
|
|
174
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
141
175
|
});
|
|
142
176
|
|
|
143
177
|
it("throws when entity has no bucket", async () => {
|
|
@@ -153,8 +187,11 @@ describe("resolveEntityContext", () => {
|
|
|
153
187
|
it("throws on entity lookup failure", async () => {
|
|
154
188
|
setupFetchMock({ entityStatus: 404 });
|
|
155
189
|
|
|
190
|
+
// The namespace lookup fails first under the new model — error
|
|
191
|
+
// message now reflects "Failed to check slug" before the
|
|
192
|
+
// entity.get(uid) step is reached.
|
|
156
193
|
await expect(resolveEntityContext("nonexistent", mockConfig)).rejects.toThrow(
|
|
157
|
-
/Failed to
|
|
194
|
+
/Failed to check slug/,
|
|
158
195
|
);
|
|
159
196
|
});
|
|
160
197
|
|
|
@@ -201,12 +238,20 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
201
238
|
expect(vendCalls[0]).toContain("/sts/vend-self");
|
|
202
239
|
});
|
|
203
240
|
|
|
204
|
-
it("foo_bar slug: entity resolved via /entity/
|
|
241
|
+
it("foo_bar slug: entity resolved via /entity/check-slug/me + /entity/<uid> and credentials via /sts/vend", async () => {
|
|
205
242
|
const calls: string[] = [];
|
|
206
243
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
207
244
|
const u = String(url);
|
|
208
245
|
calls.push(u);
|
|
209
|
-
if (u.includes("/entity/
|
|
246
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
247
|
+
return {
|
|
248
|
+
ok: true,
|
|
249
|
+
status: 200,
|
|
250
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
251
|
+
text: async () => "",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (u.includes(`/entity/${mockEntity.uid}`)) {
|
|
210
255
|
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
211
256
|
}
|
|
212
257
|
if (u.includes("/sts/vend")) {
|
|
@@ -217,19 +262,29 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
217
262
|
|
|
218
263
|
await resolveEntityContext("foo_bar", mockConfig);
|
|
219
264
|
|
|
220
|
-
expect(
|
|
265
|
+
expect(
|
|
266
|
+
calls.some((u) => u.includes("/entity/check-slug/me?type=company&slug=foo_bar")),
|
|
267
|
+
).toBe(true);
|
|
221
268
|
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
222
269
|
expect(vendCalls).toHaveLength(1);
|
|
223
270
|
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
224
271
|
expect(vendCalls[0]).toContain("/sts/vend");
|
|
225
272
|
});
|
|
226
273
|
|
|
227
|
-
it("team_alpha slug: entity resolved via /entity/
|
|
274
|
+
it("team_alpha slug: entity resolved via /entity/check-slug/me + /entity/<uid> and credentials via /sts/vend", async () => {
|
|
228
275
|
const calls: string[] = [];
|
|
229
276
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => {
|
|
230
277
|
const u = String(url);
|
|
231
278
|
calls.push(u);
|
|
232
|
-
if (u.includes("/entity/
|
|
279
|
+
if (u.includes("/entity/check-slug/me")) {
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
status: 200,
|
|
283
|
+
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
284
|
+
text: async () => "",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (u.includes(`/entity/${mockEntity.uid}`)) {
|
|
233
288
|
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
234
289
|
}
|
|
235
290
|
if (u.includes("/sts/vend")) {
|
|
@@ -240,7 +295,9 @@ describe("routing by UID prefix and vend-self dispatch", () => {
|
|
|
240
295
|
|
|
241
296
|
await resolveEntityContext("team_alpha", mockConfig);
|
|
242
297
|
|
|
243
|
-
expect(
|
|
298
|
+
expect(
|
|
299
|
+
calls.some((u) => u.includes("/entity/check-slug/me?type=company&slug=team_alpha")),
|
|
300
|
+
).toBe(true);
|
|
244
301
|
const vendCalls = calls.filter((u) => u.includes("/sts/vend"));
|
|
245
302
|
expect(vendCalls).toHaveLength(1);
|
|
246
303
|
expect(vendCalls[0]).not.toContain("/sts/vend-self");
|
|
@@ -283,7 +340,9 @@ describe("refreshEntityContext", () => {
|
|
|
283
340
|
const ctx2 = await refreshEntityContext("acme", mockConfig);
|
|
284
341
|
|
|
285
342
|
expect(ctx2).not.toBe(ctx1);
|
|
286
|
-
|
|
343
|
+
// 2 resolves × 3 fetches each = 6 under the new model
|
|
344
|
+
// (check-slug + entity.get + sts/vend each).
|
|
345
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
287
346
|
});
|
|
288
347
|
});
|
|
289
348
|
|
package/src/context.ts
CHANGED
|
@@ -14,9 +14,56 @@ const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
|
|
|
14
14
|
/** STS session duration requested from vault-service (15 minutes). */
|
|
15
15
|
const DEFAULT_SESSION_DURATION_SECONDS = 900;
|
|
16
16
|
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Cached contexts.
|
|
19
|
+
*
|
|
20
|
+
* Two keying schemes share the map:
|
|
21
|
+
* - UID keys: bare `cmp_xxx` / `prs_xxx`. Globally unique by ULID, so
|
|
22
|
+
* the cached context is safe to return to any caller asking by uid.
|
|
23
|
+
* - Slug keys: `slug:${callerSub}#${slug}`. Under the per-user-namespace
|
|
24
|
+
* model on hq-pro (PR 67, live 2026-05-15), the same slug can
|
|
25
|
+
* legitimately resolve to different company UIDs for different
|
|
26
|
+
* callers. Keying slug entries by `(callerSub, slug)` prevents one
|
|
27
|
+
* caller's resolution from being served to another caller against
|
|
28
|
+
* the wrong tenant. Codex flagged this as a P1 on PR 67's hq-cloud
|
|
29
|
+
* follow-up.
|
|
30
|
+
*/
|
|
18
31
|
const contextCache = new Map<string, EntityContext>();
|
|
19
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Extract the Cognito sub claim from a JWT without verifying the
|
|
35
|
+
* signature. We don't need verification here — the token already
|
|
36
|
+
* authorized the upstream API call; we're only using `sub` as a
|
|
37
|
+
* per-caller cache discriminator. If the token is malformed or
|
|
38
|
+
* missing a sub, fall back to a hash of the whole token (slightly
|
|
39
|
+
* larger key space, same correctness — different tokens still
|
|
40
|
+
* get distinct cache entries).
|
|
41
|
+
*/
|
|
42
|
+
function callerKeyFromAccessToken(accessToken: string): string {
|
|
43
|
+
const parts = accessToken.split(".");
|
|
44
|
+
if (parts.length === 3) {
|
|
45
|
+
try {
|
|
46
|
+
// base64url decode (Node-compatible, no padding required)
|
|
47
|
+
const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
48
|
+
const payload = Buffer.from(padded, "base64").toString("utf8");
|
|
49
|
+
const claims = JSON.parse(payload) as { sub?: unknown };
|
|
50
|
+
if (typeof claims.sub === "string" && claims.sub.length > 0) {
|
|
51
|
+
return claims.sub;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// fall through to token-hash fallback
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Stable fallback — same token → same key, different tokens → different keys.
|
|
58
|
+
// (Avoids importing crypto for a sha; the raw token suffix is sufficient as
|
|
59
|
+
// a cache discriminator and isn't exposed outside this process.)
|
|
60
|
+
return `tok:${accessToken.slice(-32)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function slugCacheKey(callerKey: string, slug: string): string {
|
|
64
|
+
return `slug:${callerKey}#${slug}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
20
67
|
/**
|
|
21
68
|
* Closed-set of recognised entity-UID prefixes. Adding a new entity type
|
|
22
69
|
* means appending one entry here AND extending the dispatch in
|
|
@@ -35,20 +82,34 @@ export async function resolveEntityContext(
|
|
|
35
82
|
companyUidOrSlug: string,
|
|
36
83
|
config: VaultServiceConfig,
|
|
37
84
|
): Promise<EntityContext> {
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return cached;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Step 1: Resolve entity — if it looks like a known UID prefix, fetch directly;
|
|
45
|
-
// otherwise look up by slug. Explicit enumeration avoids over-matching slugs
|
|
46
|
-
// like foo_bar or team_alpha that happen to look like UIDs.
|
|
85
|
+
// Step 0: Determine whether the input is a UID (globally unique) or a
|
|
86
|
+
// slug (per-caller). The cache key differs accordingly — see the
|
|
87
|
+
// `contextCache` jsdoc.
|
|
47
88
|
const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
|
|
48
89
|
companyUidOrSlug.startsWith(p),
|
|
49
90
|
);
|
|
50
91
|
const looksLikePerson = companyUidOrSlug.startsWith("prs_");
|
|
51
92
|
|
|
93
|
+
// For slug lookups, scope the cache key by the caller. Resolving the
|
|
94
|
+
// access token here (once) doubles as a way to extract the sub for
|
|
95
|
+
// the key — same hop the downstream fetch makes anyway.
|
|
96
|
+
let cacheKey: string;
|
|
97
|
+
let callerKey: string | null = null;
|
|
98
|
+
if (looksLikeUid) {
|
|
99
|
+
cacheKey = companyUidOrSlug;
|
|
100
|
+
} else {
|
|
101
|
+
const token = await resolveAuthToken(config);
|
|
102
|
+
callerKey = callerKeyFromAccessToken(token);
|
|
103
|
+
cacheKey = slugCacheKey(callerKey, companyUidOrSlug);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check cache — return if credentials still fresh.
|
|
107
|
+
const cached = contextCache.get(cacheKey);
|
|
108
|
+
if (cached && !isExpiringSoon(cached.expiresAt)) {
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Step 1: Resolve entity — direct fetch by UID or namespace lookup by slug.
|
|
52
113
|
const entity = looksLikeUid
|
|
53
114
|
? await fetchEntity(companyUidOrSlug, config)
|
|
54
115
|
: await fetchEntityBySlug("company", companyUidOrSlug, config);
|
|
@@ -81,9 +142,13 @@ export async function resolveEntityContext(
|
|
|
81
142
|
expiresAt: vendResult.expiresAt,
|
|
82
143
|
};
|
|
83
144
|
|
|
84
|
-
// Cache by
|
|
145
|
+
// Cache by UID (globally unique) and — if the caller asked by slug —
|
|
146
|
+
// by `(callerSub, slug)` so the same slug can resolve to different
|
|
147
|
+
// entities per caller without cross-caller poisoning.
|
|
85
148
|
contextCache.set(entity.uid, ctx);
|
|
86
|
-
|
|
149
|
+
if (callerKey) {
|
|
150
|
+
contextCache.set(slugCacheKey(callerKey, entity.slug), ctx);
|
|
151
|
+
}
|
|
87
152
|
|
|
88
153
|
return ctx;
|
|
89
154
|
}
|
|
@@ -105,8 +170,18 @@ export async function refreshEntityContext(
|
|
|
105
170
|
companyUidOrSlug: string,
|
|
106
171
|
config: VaultServiceConfig,
|
|
107
172
|
): Promise<EntityContext> {
|
|
108
|
-
// Evict
|
|
109
|
-
|
|
173
|
+
// Evict the entry under whichever key shape applies. UID inputs key
|
|
174
|
+
// the cache by the bare uid; slug inputs key by `(callerSub, slug)`.
|
|
175
|
+
const looksLikeUid = KNOWN_UID_PREFIXES.some((p) =>
|
|
176
|
+
companyUidOrSlug.startsWith(p),
|
|
177
|
+
);
|
|
178
|
+
if (looksLikeUid) {
|
|
179
|
+
contextCache.delete(companyUidOrSlug);
|
|
180
|
+
} else {
|
|
181
|
+
const token = await resolveAuthToken(config);
|
|
182
|
+
const callerKey = callerKeyFromAccessToken(token);
|
|
183
|
+
contextCache.delete(slugCacheKey(callerKey, companyUidOrSlug));
|
|
184
|
+
}
|
|
110
185
|
return resolveEntityContext(companyUidOrSlug, config);
|
|
111
186
|
}
|
|
112
187
|
|
|
@@ -171,17 +246,38 @@ async function fetchEntityBySlug(
|
|
|
171
246
|
slug: string,
|
|
172
247
|
config: VaultServiceConfig,
|
|
173
248
|
): Promise<EntityResponse> {
|
|
174
|
-
|
|
249
|
+
// Resolve the slug inside the CALLER's namespace via the new
|
|
250
|
+
// `/entity/check-slug/me` endpoint added in hq-pro PR 67 (live
|
|
251
|
+
// in prod 2026-05-15). Under the per-user-namespace model the
|
|
252
|
+
// legacy `/entity/by-slug/{type}/{slug}` either over-matches
|
|
253
|
+
// (returns another tenant's entity when more than one user holds
|
|
254
|
+
// the slug) or 409s with SlugNotUniqueError — both wrong for the
|
|
255
|
+
// sync runner's "I want MY company" intent.
|
|
256
|
+
const checkUrl = `${config.apiUrl}/entity/check-slug/me?type=${encodeURIComponent(type)}&slug=${encodeURIComponent(slug)}`;
|
|
257
|
+
const checkRes = await fetch(checkUrl, {
|
|
175
258
|
headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
|
|
176
259
|
});
|
|
177
|
-
if (!
|
|
178
|
-
const body = await
|
|
260
|
+
if (!checkRes.ok) {
|
|
261
|
+
const body = await checkRes.text();
|
|
179
262
|
throw new Error(
|
|
180
|
-
`Failed to
|
|
263
|
+
`Failed to check slug ${type}/${slug}: ${checkRes.status} ${body}`,
|
|
181
264
|
);
|
|
182
265
|
}
|
|
183
|
-
const
|
|
184
|
-
|
|
266
|
+
const check = (await checkRes.json()) as {
|
|
267
|
+
available: boolean;
|
|
268
|
+
conflictingCompanyUid?: string;
|
|
269
|
+
};
|
|
270
|
+
if (check.available || !check.conflictingCompanyUid) {
|
|
271
|
+
// The slug isn't in the caller's namespace. From the sync
|
|
272
|
+
// runner's perspective this is a genuine "not found" — the
|
|
273
|
+
// caller doesn't have a `${slug}` to sync, even if some other
|
|
274
|
+
// user does.
|
|
275
|
+
throw new Error(
|
|
276
|
+
`No entity in caller's namespace matches ${type}/${slug}. ` +
|
|
277
|
+
`If you expected to find one, verify you're signed in to the right HQ identity.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return fetchEntity(check.conflictingCompanyUid, config);
|
|
185
281
|
}
|
|
186
282
|
|
|
187
283
|
async function postVend(
|
package/src/journal.ts
CHANGED
|
@@ -74,6 +74,39 @@ export function hashFile(filePath: string): string {
|
|
|
74
74
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Marker prepended to a symlink's target string before hashing for the
|
|
79
|
+
* journal. Mirrors the wire-side `SYMLINK_BODY_PREFIX` constant in
|
|
80
|
+
* `s3.ts` — same purpose, different namespace.
|
|
81
|
+
*
|
|
82
|
+
* Without this marker, a symlink to `real.md` and a regular file whose
|
|
83
|
+
* contents are exactly the bytes `real.md` produce identical journal
|
|
84
|
+
* hashes (both `sha256("real.md")`). When `skipUnchanged` is enabled,
|
|
85
|
+
* the planner would treat a regular-file → symlink replacement as
|
|
86
|
+
* "no change" and never upload the new symlink, leaving the remote
|
|
87
|
+
* representation stale forever — the pull side would then also see no
|
|
88
|
+
* drift via ETag and never repair.
|
|
89
|
+
*
|
|
90
|
+
* Hashing `sha256(prefix + target)` makes the two representations
|
|
91
|
+
* structurally inequal in journal-hash space, so skip-unchanged can
|
|
92
|
+
* never confuse them. The hash always varies with the target string,
|
|
93
|
+
* so target rewrites still re-fire uploads as expected.
|
|
94
|
+
*/
|
|
95
|
+
export const SYMLINK_HASH_PREFIX = "hq-symlink:";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compute the journal hash for a symlink. Always use this helper
|
|
99
|
+
* (never inline `crypto.createHash` with the raw target) so the
|
|
100
|
+
* push side, the pull-planner, and the post-download stamp stay in
|
|
101
|
+
* lockstep on the prefixed-hash convention.
|
|
102
|
+
*/
|
|
103
|
+
export function hashSymlinkTarget(target: string): string {
|
|
104
|
+
return crypto
|
|
105
|
+
.createHash("sha256")
|
|
106
|
+
.update(SYMLINK_HASH_PREFIX + target)
|
|
107
|
+
.digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
77
110
|
export function updateEntry(
|
|
78
111
|
journal: SyncJournal,
|
|
79
112
|
relativePath: string,
|