cursedbelt-core 1.1.0 → 2.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/dist/core/master-lock/index.d.ts +1 -1
- package/dist/core/master-lock/wire.d.ts +104 -3
- package/dist/core/master-lock/wire.js +24 -0
- package/package.json +2 -2
- package/src/core/master-lock/index.ts +5 -0
- package/src/core/master-lock/wire.spec.ts +33 -0
- package/src/core/master-lock/wire.ts +106 -3
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/shippedFilesAreTracked.spec.ts +69 -0
|
@@ -10,5 +10,5 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { MASTER_LOCK_KDF, type MasterLockKdfParams, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf";
|
|
12
12
|
export { BASE_DELAY_MS, DEFAULT_IDLE_MS, FREE_ATTEMPTS, MAX_DELAY_MS, MAX_IDLE_MS, MIN_IDLE_MS, clampIdleMs, delayAfter, windowRemaining, } from "./policy";
|
|
13
|
-
export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, type MasterLockChangeRequest, type MasterLockEnrollRequest, type MasterLockRefusal, type MasterLockSettingsRequest, type MasterLockStatus, type MasterLockUnlockRequest, } from "./wire";
|
|
13
|
+
export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, type MasterLockAccountSummary, type MasterLockAccountsResponse, type MasterLockAddAccountRequest, type MasterLockChangeRequest, type MasterLockEditAccountRequest, type MasterLockEnrollRequest, type MasterLockHintsResponse, type MasterLockRefusal, type MasterLockSettingsRequest, type MasterLockStatus, type MasterLockUnlockRequest, } from "./wire";
|
|
14
14
|
export { PRESENCE_ATTRIBUTE, type PresenceMedia, isPresenceMedia, isSomebodyWatching, } from "./presence";
|
|
@@ -36,6 +36,30 @@ export declare const MASTER_LOCK_PATHS: {
|
|
|
36
36
|
* current one.
|
|
37
37
|
*/
|
|
38
38
|
readonly enroll: "/__lock/enroll";
|
|
39
|
+
/**
|
|
40
|
+
* The ACCOUNTS surface — `GET` lists them, `POST` adds one, `PATCH` renames one or
|
|
41
|
+
* rewrites its hint.
|
|
42
|
+
*
|
|
43
|
+
* 🔴 Reachable only with a live unlock, unlike everything above it. Listing the
|
|
44
|
+
* accounts says how many master passwords exist and what they are called, which is
|
|
45
|
+
* the one fact the lock page is built never to disclose (see {@link hints}).
|
|
46
|
+
*/
|
|
47
|
+
readonly accounts: "/__lock/accounts";
|
|
48
|
+
/**
|
|
49
|
+
* The hints, and the ONE thing here that is readable while locked.
|
|
50
|
+
*
|
|
51
|
+
* The owner asked for it in as many words: *"let me setup hints for them … since the
|
|
52
|
+
* passwords are not recorded anywhere I will want a way to help me if I forget it."*
|
|
53
|
+
* A hint nobody can read until they are already in is not a hint.
|
|
54
|
+
*
|
|
55
|
+
* 🔴 It is a SEPARATE route rather than a field on {@link MasterLockStatus} precisely
|
|
56
|
+
* because it discloses what status must not: the number of accounts and their labels.
|
|
57
|
+
* The lock page fetches it only when the person clicks "I forgot", so the default
|
|
58
|
+
* page is byte-identical whether there is one account or ten, and revealing the list
|
|
59
|
+
* is a deliberate act rather than a side effect of loading a page. Everyone who can
|
|
60
|
+
* reach it has already passed the app's own owner-only sign-in.
|
|
61
|
+
*/
|
|
62
|
+
readonly hints: "/__lock/hints";
|
|
39
63
|
};
|
|
40
64
|
/**
|
|
41
65
|
* The base name of the cookie the unlock token rides in. Callers want
|
|
@@ -112,6 +136,71 @@ export interface MasterLockStatus {
|
|
|
112
136
|
remainingMs: number;
|
|
113
137
|
/** Milliseconds until another guess will be evaluated. `0` normally. */
|
|
114
138
|
retryAfterMs: number;
|
|
139
|
+
/**
|
|
140
|
+
* WHICH account the caller's unlock belongs to, or `null` when locked.
|
|
141
|
+
*
|
|
142
|
+
* 🔴 This is the whole point of the accounts model and it is the only account fact
|
|
143
|
+
* status carries. The master password a person types decides which tenant's data the
|
|
144
|
+
* app serves them, so the app needs to know which one opened — but nothing here says
|
|
145
|
+
* how many OTHER accounts exist, because that would let a stolen session count the
|
|
146
|
+
* owner's tenants by loading a page. See {@link MASTER_LOCK_PATHS.hints}.
|
|
147
|
+
*/
|
|
148
|
+
accountId: string | null;
|
|
149
|
+
/** The label of {@link accountId}, for a header chip. `null` when locked. */
|
|
150
|
+
accountLabel: string | null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* One account, as the management UI is allowed to see it.
|
|
154
|
+
*
|
|
155
|
+
* 🔴 Never the verifier hash and never anything derived from the password. The `hint` IS
|
|
156
|
+
* disclosed, because a hint the owner cannot read is not a hint — it is their own words,
|
|
157
|
+
* written by them, for the moment they have forgotten which password opens which tenant.
|
|
158
|
+
*/
|
|
159
|
+
export interface MasterLockAccountSummary {
|
|
160
|
+
id: string;
|
|
161
|
+
label: string;
|
|
162
|
+
/** The owner's own reminder. Empty string when they set none. */
|
|
163
|
+
hint: string;
|
|
164
|
+
createdAt: number;
|
|
165
|
+
/** Is this the account the caller's own unlock belongs to? */
|
|
166
|
+
current: boolean;
|
|
167
|
+
}
|
|
168
|
+
/** What `GET /__lock/accounts` answers — requires a live unlock. */
|
|
169
|
+
export interface MasterLockAccountsResponse {
|
|
170
|
+
accounts: MasterLockAccountSummary[];
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Adding an account: a SECOND master password, and therefore a second tenant.
|
|
174
|
+
*
|
|
175
|
+
* The current password is re-typed for the same reason {@link MasterLockChangeRequest}
|
|
176
|
+
* demands it — an open session proves somebody is at the keyboard, not that it is the
|
|
177
|
+
* owner, and minting a whole new tenant is at least as consequential as rotating one.
|
|
178
|
+
*
|
|
179
|
+
* 🔴 There is no `kdf`. Every account on an app derives under the SAME params, so one
|
|
180
|
+
* PBKDF2 pass in the browser can be measured against all of them and the lock page
|
|
181
|
+
* cannot be made to disclose how many exist by how many salts it has to publish. See
|
|
182
|
+
* the engine's note on the shared descriptor.
|
|
183
|
+
*/
|
|
184
|
+
export interface MasterLockAddAccountRequest {
|
|
185
|
+
/** Derived under the app's params — proof the caller knows the password they are in. */
|
|
186
|
+
currentVerifier: string;
|
|
187
|
+
/** The NEW account's verifier, derived under those same params. */
|
|
188
|
+
verifier: string;
|
|
189
|
+
label: string;
|
|
190
|
+
hint?: string;
|
|
191
|
+
}
|
|
192
|
+
/** Renaming an account or rewriting its hint. Never its password — that is `change`. */
|
|
193
|
+
export interface MasterLockEditAccountRequest {
|
|
194
|
+
id: string;
|
|
195
|
+
label?: string;
|
|
196
|
+
hint?: string;
|
|
197
|
+
}
|
|
198
|
+
/** What `GET /__lock/hints` answers, readable WHILE LOCKED — see the path's note. */
|
|
199
|
+
export interface MasterLockHintsResponse {
|
|
200
|
+
hints: {
|
|
201
|
+
label: string;
|
|
202
|
+
hint: string;
|
|
203
|
+
}[];
|
|
115
204
|
}
|
|
116
205
|
export interface MasterLockUnlockRequest {
|
|
117
206
|
verifier: string;
|
|
@@ -122,11 +211,23 @@ export interface MasterLockEnrollRequest {
|
|
|
122
211
|
verifier: string;
|
|
123
212
|
}
|
|
124
213
|
export interface MasterLockChangeRequest {
|
|
125
|
-
/** Derived under the
|
|
214
|
+
/** Derived under the app's params — proof that the person changing it is the person
|
|
126
215
|
* who knows it, re-typed rather than inherited from the open session. */
|
|
127
216
|
currentVerifier: string;
|
|
128
|
-
/**
|
|
129
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The new verifier, derived under those SAME params.
|
|
219
|
+
*
|
|
220
|
+
* 🔴 It used to carry a fresh `kdf`, and it may not any more. The params are now one
|
|
221
|
+
* app-wide descriptor that every account derives under, so minting a new salt here
|
|
222
|
+
* would silently invalidate every OTHER account's stored hash — the owner would
|
|
223
|
+
* change account 2's password and lock himself out of account 1. The cost is that the
|
|
224
|
+
* salt never rotates; it is 16 random bytes whose job is to stop precomputation
|
|
225
|
+
* across apps, and it does that for as long as it exists.
|
|
226
|
+
*
|
|
227
|
+
* Rotating a password never changes which tenant it opens. That is the owner's
|
|
228
|
+
* requirement, in his words: *"If I change account 2 password it just changes how I
|
|
229
|
+
* get the access … I would still access the same content afterward."*
|
|
230
|
+
*/
|
|
130
231
|
verifier: string;
|
|
131
232
|
}
|
|
132
233
|
export interface MasterLockSettingsRequest {
|
|
@@ -29,6 +29,30 @@ export const MASTER_LOCK_PATHS = {
|
|
|
29
29
|
* current one.
|
|
30
30
|
*/
|
|
31
31
|
enroll: `${MASTER_LOCK_PREFIX}/enroll`,
|
|
32
|
+
/**
|
|
33
|
+
* The ACCOUNTS surface — `GET` lists them, `POST` adds one, `PATCH` renames one or
|
|
34
|
+
* rewrites its hint.
|
|
35
|
+
*
|
|
36
|
+
* 🔴 Reachable only with a live unlock, unlike everything above it. Listing the
|
|
37
|
+
* accounts says how many master passwords exist and what they are called, which is
|
|
38
|
+
* the one fact the lock page is built never to disclose (see {@link hints}).
|
|
39
|
+
*/
|
|
40
|
+
accounts: `${MASTER_LOCK_PREFIX}/accounts`,
|
|
41
|
+
/**
|
|
42
|
+
* The hints, and the ONE thing here that is readable while locked.
|
|
43
|
+
*
|
|
44
|
+
* The owner asked for it in as many words: *"let me setup hints for them … since the
|
|
45
|
+
* passwords are not recorded anywhere I will want a way to help me if I forget it."*
|
|
46
|
+
* A hint nobody can read until they are already in is not a hint.
|
|
47
|
+
*
|
|
48
|
+
* 🔴 It is a SEPARATE route rather than a field on {@link MasterLockStatus} precisely
|
|
49
|
+
* because it discloses what status must not: the number of accounts and their labels.
|
|
50
|
+
* The lock page fetches it only when the person clicks "I forgot", so the default
|
|
51
|
+
* page is byte-identical whether there is one account or ten, and revealing the list
|
|
52
|
+
* is a deliberate act rather than a side effect of loading a page. Everyone who can
|
|
53
|
+
* reach it has already passed the app's own owner-only sign-in.
|
|
54
|
+
*/
|
|
55
|
+
hints: `${MASTER_LOCK_PREFIX}/hints`,
|
|
32
56
|
};
|
|
33
57
|
/**
|
|
34
58
|
* The base name of the cookie the unlock token rides in. Callers want
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split \u2014 isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -356,7 +356,7 @@
|
|
|
356
356
|
},
|
|
357
357
|
"dependencies": {
|
|
358
358
|
"@leeoniya/ufuzzy": "^1.0.19",
|
|
359
|
-
"cwip": "^
|
|
359
|
+
"cwip": "^4.1.0"
|
|
360
360
|
},
|
|
361
361
|
"peerDependencies": {
|
|
362
362
|
"zod": "^4.4.0"
|
|
@@ -35,8 +35,13 @@ export {
|
|
|
35
35
|
MASTER_LOCK_PATHS,
|
|
36
36
|
MASTER_LOCK_PREFIX,
|
|
37
37
|
MASTER_LOCK_STATE_HEADER,
|
|
38
|
+
type MasterLockAccountSummary,
|
|
39
|
+
type MasterLockAccountsResponse,
|
|
40
|
+
type MasterLockAddAccountRequest,
|
|
38
41
|
type MasterLockChangeRequest,
|
|
42
|
+
type MasterLockEditAccountRequest,
|
|
39
43
|
type MasterLockEnrollRequest,
|
|
44
|
+
type MasterLockHintsResponse,
|
|
40
45
|
type MasterLockRefusal,
|
|
41
46
|
type MasterLockSettingsRequest,
|
|
42
47
|
type MasterLockStatus,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire's own shape — the two properties a typo here would break silently.
|
|
3
|
+
*
|
|
4
|
+
* Every surface of this feature is a string constant in one object, and two of them
|
|
5
|
+
* arrived together (`accounts`, `hints`) with very different disclosure rules: one is
|
|
6
|
+
* reachable only with a live unlock, the other is readable while locked. A copy-paste that
|
|
7
|
+
* gave them the same string would route the management surface to the public one, and
|
|
8
|
+
* every unit test either side of the wire would stay green — the server would mount what
|
|
9
|
+
* it was told to mount and the browser would fetch what it was told to fetch.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "./wire";
|
|
13
|
+
|
|
14
|
+
describe("MASTER_LOCK_PATHS", () => {
|
|
15
|
+
it("keeps every surface under the one prefix the guard reserves", () => {
|
|
16
|
+
for (const [name, path] of Object.entries(MASTER_LOCK_PATHS)) {
|
|
17
|
+
expect(`${name}:${path}`).toBe(`${name}:${path}`);
|
|
18
|
+
expect(path.startsWith(`${MASTER_LOCK_PREFIX}/`)).toBe(true);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("gives every surface its own path", () => {
|
|
23
|
+
const paths = Object.values(MASTER_LOCK_PATHS);
|
|
24
|
+
expect(new Set(paths).size).toBe(paths.length);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps the locked-readable hints apart from the unlock-only accounts surface", () => {
|
|
28
|
+
// The one pair whose collision would be a disclosure rather than a 404.
|
|
29
|
+
expect(MASTER_LOCK_PATHS.hints).not.toBe(MASTER_LOCK_PATHS.accounts);
|
|
30
|
+
expect(MASTER_LOCK_PATHS.accounts.startsWith(MASTER_LOCK_PATHS.hints)).toBe(false);
|
|
31
|
+
expect(MASTER_LOCK_PATHS.hints.startsWith(MASTER_LOCK_PATHS.accounts)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -38,6 +38,30 @@ export const MASTER_LOCK_PATHS = {
|
|
|
38
38
|
* current one.
|
|
39
39
|
*/
|
|
40
40
|
enroll: `${MASTER_LOCK_PREFIX}/enroll`,
|
|
41
|
+
/**
|
|
42
|
+
* The ACCOUNTS surface — `GET` lists them, `POST` adds one, `PATCH` renames one or
|
|
43
|
+
* rewrites its hint.
|
|
44
|
+
*
|
|
45
|
+
* 🔴 Reachable only with a live unlock, unlike everything above it. Listing the
|
|
46
|
+
* accounts says how many master passwords exist and what they are called, which is
|
|
47
|
+
* the one fact the lock page is built never to disclose (see {@link hints}).
|
|
48
|
+
*/
|
|
49
|
+
accounts: `${MASTER_LOCK_PREFIX}/accounts`,
|
|
50
|
+
/**
|
|
51
|
+
* The hints, and the ONE thing here that is readable while locked.
|
|
52
|
+
*
|
|
53
|
+
* The owner asked for it in as many words: *"let me setup hints for them … since the
|
|
54
|
+
* passwords are not recorded anywhere I will want a way to help me if I forget it."*
|
|
55
|
+
* A hint nobody can read until they are already in is not a hint.
|
|
56
|
+
*
|
|
57
|
+
* 🔴 It is a SEPARATE route rather than a field on {@link MasterLockStatus} precisely
|
|
58
|
+
* because it discloses what status must not: the number of accounts and their labels.
|
|
59
|
+
* The lock page fetches it only when the person clicks "I forgot", so the default
|
|
60
|
+
* page is byte-identical whether there is one account or ten, and revealing the list
|
|
61
|
+
* is a deliberate act rather than a side effect of loading a page. Everyone who can
|
|
62
|
+
* reach it has already passed the app's own owner-only sign-in.
|
|
63
|
+
*/
|
|
64
|
+
hints: `${MASTER_LOCK_PREFIX}/hints`,
|
|
41
65
|
} as const;
|
|
42
66
|
|
|
43
67
|
/**
|
|
@@ -123,6 +147,73 @@ export interface MasterLockStatus {
|
|
|
123
147
|
remainingMs: number;
|
|
124
148
|
/** Milliseconds until another guess will be evaluated. `0` normally. */
|
|
125
149
|
retryAfterMs: number;
|
|
150
|
+
/**
|
|
151
|
+
* WHICH account the caller's unlock belongs to, or `null` when locked.
|
|
152
|
+
*
|
|
153
|
+
* 🔴 This is the whole point of the accounts model and it is the only account fact
|
|
154
|
+
* status carries. The master password a person types decides which tenant's data the
|
|
155
|
+
* app serves them, so the app needs to know which one opened — but nothing here says
|
|
156
|
+
* how many OTHER accounts exist, because that would let a stolen session count the
|
|
157
|
+
* owner's tenants by loading a page. See {@link MASTER_LOCK_PATHS.hints}.
|
|
158
|
+
*/
|
|
159
|
+
accountId: string | null;
|
|
160
|
+
/** The label of {@link accountId}, for a header chip. `null` when locked. */
|
|
161
|
+
accountLabel: string | null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One account, as the management UI is allowed to see it.
|
|
166
|
+
*
|
|
167
|
+
* 🔴 Never the verifier hash and never anything derived from the password. The `hint` IS
|
|
168
|
+
* disclosed, because a hint the owner cannot read is not a hint — it is their own words,
|
|
169
|
+
* written by them, for the moment they have forgotten which password opens which tenant.
|
|
170
|
+
*/
|
|
171
|
+
export interface MasterLockAccountSummary {
|
|
172
|
+
id: string;
|
|
173
|
+
label: string;
|
|
174
|
+
/** The owner's own reminder. Empty string when they set none. */
|
|
175
|
+
hint: string;
|
|
176
|
+
createdAt: number;
|
|
177
|
+
/** Is this the account the caller's own unlock belongs to? */
|
|
178
|
+
current: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What `GET /__lock/accounts` answers — requires a live unlock. */
|
|
182
|
+
export interface MasterLockAccountsResponse {
|
|
183
|
+
accounts: MasterLockAccountSummary[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Adding an account: a SECOND master password, and therefore a second tenant.
|
|
188
|
+
*
|
|
189
|
+
* The current password is re-typed for the same reason {@link MasterLockChangeRequest}
|
|
190
|
+
* demands it — an open session proves somebody is at the keyboard, not that it is the
|
|
191
|
+
* owner, and minting a whole new tenant is at least as consequential as rotating one.
|
|
192
|
+
*
|
|
193
|
+
* 🔴 There is no `kdf`. Every account on an app derives under the SAME params, so one
|
|
194
|
+
* PBKDF2 pass in the browser can be measured against all of them and the lock page
|
|
195
|
+
* cannot be made to disclose how many exist by how many salts it has to publish. See
|
|
196
|
+
* the engine's note on the shared descriptor.
|
|
197
|
+
*/
|
|
198
|
+
export interface MasterLockAddAccountRequest {
|
|
199
|
+
/** Derived under the app's params — proof the caller knows the password they are in. */
|
|
200
|
+
currentVerifier: string;
|
|
201
|
+
/** The NEW account's verifier, derived under those same params. */
|
|
202
|
+
verifier: string;
|
|
203
|
+
label: string;
|
|
204
|
+
hint?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Renaming an account or rewriting its hint. Never its password — that is `change`. */
|
|
208
|
+
export interface MasterLockEditAccountRequest {
|
|
209
|
+
id: string;
|
|
210
|
+
label?: string;
|
|
211
|
+
hint?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What `GET /__lock/hints` answers, readable WHILE LOCKED — see the path's note. */
|
|
215
|
+
export interface MasterLockHintsResponse {
|
|
216
|
+
hints: { label: string; hint: string }[];
|
|
126
217
|
}
|
|
127
218
|
|
|
128
219
|
export interface MasterLockUnlockRequest {
|
|
@@ -136,11 +227,23 @@ export interface MasterLockEnrollRequest {
|
|
|
136
227
|
}
|
|
137
228
|
|
|
138
229
|
export interface MasterLockChangeRequest {
|
|
139
|
-
/** Derived under the
|
|
230
|
+
/** Derived under the app's params — proof that the person changing it is the person
|
|
140
231
|
* who knows it, re-typed rather than inherited from the open session. */
|
|
141
232
|
currentVerifier: string;
|
|
142
|
-
/**
|
|
143
|
-
|
|
233
|
+
/**
|
|
234
|
+
* The new verifier, derived under those SAME params.
|
|
235
|
+
*
|
|
236
|
+
* 🔴 It used to carry a fresh `kdf`, and it may not any more. The params are now one
|
|
237
|
+
* app-wide descriptor that every account derives under, so minting a new salt here
|
|
238
|
+
* would silently invalidate every OTHER account's stored hash — the owner would
|
|
239
|
+
* change account 2's password and lock himself out of account 1. The cost is that the
|
|
240
|
+
* salt never rotates; it is 16 random bytes whose job is to stop precomputation
|
|
241
|
+
* across apps, and it does that for as long as it exists.
|
|
242
|
+
*
|
|
243
|
+
* Rotating a password never changes which tenant it opens. That is the owner's
|
|
244
|
+
* requirement, in his words: *"If I change account 2 password it just changes how I
|
|
245
|
+
* get the access … I would still access the same content afterward."*
|
|
246
|
+
*/
|
|
144
247
|
verifier: string;
|
|
145
248
|
}
|
|
146
249
|
|
|
@@ -152,8 +152,10 @@ const sourceOf = (subpath: string): string => {
|
|
|
152
152
|
* Resolution here is therefore explicit rather than inherited. The redirected root is
|
|
153
153
|
* used when it lies outside the repo (the normal case — the runner always sets
|
|
154
154
|
* `$FORGE_STATE`, and the preload's sweep then cleans up after a killed run). When it
|
|
155
|
-
* does not, this hops to `$
|
|
156
|
-
*
|
|
155
|
+
* does not, this hops to `$FORGE_STATE/test-scratch.noindex` — or, with no generation
|
|
156
|
+
* in the environment, `~/.code/test-scratch.noindex`, which is where this machine keeps
|
|
157
|
+
* everything it builds for itself. `resolveTestTmpRoot` refuses `$HOME` for the general
|
|
158
|
+
* default and this check genuinely requires an isolated root: that isolation is the whole
|
|
157
159
|
* measurement. Stale siblings are swept by mtime, the same way the preload does it,
|
|
158
160
|
* so the hop cannot accumulate.
|
|
159
161
|
*/
|
|
@@ -171,7 +173,17 @@ const fixtureRoot = (): string => {
|
|
|
171
173
|
'a check that cannot isolate itself is not a passing check',
|
|
172
174
|
);
|
|
173
175
|
}
|
|
174
|
-
|
|
176
|
+
// 🔴 Under `$FORGE_STATE`, or under `~/.code` — never a dotted directory of its own in
|
|
177
|
+
// `$HOME`. Measured 2026-09-17: this hop had left three `~/.cursedbelt*-leaf-fixture.noindex`
|
|
178
|
+
// trees in the owner's home directory, indistinguishable at a glance from the forty a Mac's
|
|
179
|
+
// real software puts there, and the sweep below only ever cleaned their INSIDES. A check that
|
|
180
|
+
// tidies up after itself and still leaves its address behind for ever is the shape this
|
|
181
|
+
// generation's home-dir rule exists to stop; `tools/check-home-dir.ts` now fails on it.
|
|
182
|
+
const state = process.env.FORGE_STATE?.trim();
|
|
183
|
+
const outside =
|
|
184
|
+
state !== undefined && state !== '' && !`${state}/`.startsWith(REPO_PREFIX)
|
|
185
|
+
? `${state}/test-scratch.noindex/${pkg.name}-leaf-fixture`
|
|
186
|
+
: `${home}/.code/test-scratch.noindex/${pkg.name}-leaf-fixture`;
|
|
175
187
|
try {
|
|
176
188
|
const cutoff = Date.now() - 6 * 60 * 60 * 1000;
|
|
177
189
|
for (const entry of readdirSync(outside)) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 `bun publish` packs the WORKING DIRECTORY, not the git index — so a package can
|
|
3
|
+
* ship a file the repo does not have.
|
|
4
|
+
*
|
|
5
|
+
* ── What this cost, 2026-09-15 ─────────────────────────────────────────────────────
|
|
6
|
+
* The cursedbelt split (task 148) moved two integration specs into this package from
|
|
7
|
+
* `cursedbelt`. `git add -u .` stages only files git already knows about, so both were
|
|
8
|
+
* committed nowhere — and `bun run verify` was green *because they were present on disk*,
|
|
9
|
+
* which is the same reason nobody noticed. `1.0.2` went to the registry carrying two
|
|
10
|
+
* `src/` files that existed in no commit, and `git status` said `tracked-dirty=0`.
|
|
11
|
+
*
|
|
12
|
+
* "The tree is clean" and "everything that ships is committed" are different claims, and
|
|
13
|
+
* only the first one is what people actually check. npm versions are immutable, so the
|
|
14
|
+
* window to notice closes at publish time.
|
|
15
|
+
*
|
|
16
|
+
* ── Why this shape ─────────────────────────────────────────────────────────────────
|
|
17
|
+
* It asks git, for exactly the directories `package.json#files` promises to ship, which
|
|
18
|
+
* of those paths git does not track. That is narrower than "any untracked file" on
|
|
19
|
+
* purpose: scratch files, `.agent.noindex/`, a half-written script and an uncommitted
|
|
20
|
+
* lockfile are all legitimate states for a working tree and reddening on them would teach
|
|
21
|
+
* people to skip this. A file INSIDE the published surface is the only case where
|
|
22
|
+
* untracked means "about to ship something no one can review or revert".
|
|
23
|
+
*
|
|
24
|
+
* 🔴 It must run from a real checkout to mean anything, so it refuses rather than passes
|
|
25
|
+
* when `git` cannot answer — a check that silently degrades to green is worse than none.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, expect, test } from 'bun:test';
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { existsSync } from 'node:fs';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import pkg from '../package.json';
|
|
32
|
+
|
|
33
|
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
34
|
+
|
|
35
|
+
/** The directories `files` promises to ship, minus build output nobody commits. */
|
|
36
|
+
const shippedDirs = (): string[] =>
|
|
37
|
+
((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
|
|
38
|
+
|
|
39
|
+
const git = (...args: string[]): { ok: boolean; out: string } => {
|
|
40
|
+
const r = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });
|
|
41
|
+
return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('every file inside the published surface is tracked', () => {
|
|
45
|
+
test('git can answer at all — a degraded check must refuse, not pass', () => {
|
|
46
|
+
expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('package.json names a shippable directory to scan', () => {
|
|
50
|
+
// Vacuity guard: an empty `files`, or one naming only `dist`, makes the assertion
|
|
51
|
+
// below pass while scanning nothing at all.
|
|
52
|
+
expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('no untracked file sits inside a directory `files` ships', () => {
|
|
56
|
+
// --others = untracked; --exclude-standard honours .gitignore, so deliberately
|
|
57
|
+
// ignored paths (.agent.noindex, node_modules) are not offences.
|
|
58
|
+
const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
59
|
+
expect(ok, 'git ls-files failed').toBe(true);
|
|
60
|
+
const untracked = out === '' ? [] : out.split('\n');
|
|
61
|
+
expect(
|
|
62
|
+
untracked,
|
|
63
|
+
'these would be PUBLISHED but exist in no commit — `bun publish` packs the working directory, not the index,\n' +
|
|
64
|
+
'so the registry would carry code nobody can review, diff or revert, and npm versions are immutable.\n' +
|
|
65
|
+
'Commit them (or add them to .gitignore if they genuinely must not ship):\n ' +
|
|
66
|
+
untracked.join('\n '),
|
|
67
|
+
).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|