@cortexkit/opencode-openai-auth 1.0.3 → 1.0.5
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/README.md +12 -60
- package/dist/bin.js +13 -0
- package/dist/index-116q849r.js +2 -0
- package/dist/index-4fnghj9e.js +65 -0
- package/dist/index-66asrje2.js +2 -0
- package/dist/index-826s1yxa.js +2 -0
- package/dist/index-a69gr76b.js +2 -0
- package/dist/index-fsky1rsh.js +12 -0
- package/dist/index-fsxbncpw.js +3 -0
- package/dist/index-h263aftt.js +157 -0
- package/dist/{index-hy9030jh.js → index-nx2qh047.js} +1 -1
- package/dist/{index-766sfrh5.js → index-pg305s90.js} +1 -1
- package/dist/index-ssj7m2rd.js +8 -0
- package/dist/index-y4gxyxqd.js +2 -0
- package/dist/index-ydz3hbhc.js +2 -0
- package/dist/index.d.ts +2 -56
- package/dist/index.js +1 -1
- package/dist/map-m6f5a_IA.d.ts +56 -0
- package/dist/{pool-lifecycle-YLz2B8A5.d.ts → pool-lifecycle-C_6YRmsh.d.ts} +99 -789
- package/dist/{port-file-Do3lQIEk.d.ts → port-file-ZPlxXB4G.d.ts} +1 -1
- package/dist/{protocol-BOBdkqqm.d.ts → protocol-DctqemRI.d.ts} +1 -1
- package/dist/rpc/port-file.d.ts +1 -1
- package/dist/rpc/port-file.js +1 -1
- package/dist/rpc/protocol.d.ts +1 -1
- package/dist/rpc/protocol.js +1 -1
- package/dist/rpc/rpc-client.d.ts +3 -3
- package/dist/rpc/rpc-client.js +1 -1
- package/dist/rpc/rpc-dir.js +1 -1
- package/dist/{sidebar-state-D6cUKgkm.d.ts → sidebar-state-z_M7Trnd.d.ts} +5 -3
- package/dist/sidebar-state.d.ts +1 -1
- package/dist/sidebar-state.js +1 -1
- package/dist/{token-ypk61xj0.js → token-bzhkgc29.js} +1 -1
- package/dist/token-util-4a2xxnpg.js +1 -0
- package/dist/tui-preferences.d.ts +2 -1
- package/dist/tui-preferences.js +3 -3
- package/dist/tui.d.ts +2 -2
- package/dist/v2/server.d.ts +1 -1
- package/dist/v2/server.js +3 -4
- package/package.json +13 -8
- package/src/tui-compiled/raw/logger.js +10 -2
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +65 -34
- package/src/tui-compiled/raw/shared/053cc17b5419261e-codec.js +2 -1
- package/src/tui-compiled/raw/shared/0766d78c6f481f18-watcher.js +26 -10
- package/src/tui-compiled/raw/shared/375d9cca2def037d-private-files.js +302 -0
- package/src/tui-compiled/raw/shared/39c3cb05738fcf6d-projection.js +3 -1
- package/src/tui-compiled/raw/shared/43fc9e6b994519c7-refresh-lock.js +5 -0
- package/src/tui-compiled/raw/shared/458b934e92490331-mutate.js +2 -2
- package/src/tui-compiled/raw/shared/4825e03209d6ae3e-with-lock.js +4 -0
- package/src/tui-compiled/raw/shared/4f36d333d081d040-merge.js +21 -5
- package/src/tui-compiled/raw/shared/7211cfec06a298bc-atomic-write.js +21 -0
- package/src/tui-compiled/raw/shared/905bffc0220f43d0-map.js +36 -1
- package/src/tui-compiled/raw/shared/a09b6a533c11858f-tui-preferences.js +6 -4
- package/src/tui-compiled/raw/shared/c14988621dc60a75-port-file.js +30 -8
- package/src/tui-compiled/raw/shared/c64d7afbc14ca125-sidebar-file.js +17 -8
- package/src/tui-compiled/raw/shared/c852a15cfe489d12-pool.js +3 -0
- package/src/tui-compiled/raw/shared/cd94316a888b0a8e-schema.js +3 -2
- package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +2 -2
- package/src/tui-compiled/raw/shared/d7dcba96fea1685c-engine.js +126 -11
- package/src/tui-compiled/raw/shared/e8cd4929507508d8-refresh-file-lock.js +254 -37
- package/src/tui-compiled/raw/shared/ec971142548df90e-index.js +1 -0
- package/src/tui-compiled/raw/shared/fbcdcd0f925e74c0-rpc-client.js +15 -1
- package/src/tui-compiled/runtime/logger.js +10 -2
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +65 -34
- package/src/tui-compiled/runtime/shared/053cc17b5419261e-codec.js +2 -1
- package/src/tui-compiled/runtime/shared/0766d78c6f481f18-watcher.js +26 -10
- package/src/tui-compiled/runtime/shared/375d9cca2def037d-private-files.js +302 -0
- package/src/tui-compiled/runtime/shared/39c3cb05738fcf6d-projection.js +3 -1
- package/src/tui-compiled/runtime/shared/43fc9e6b994519c7-refresh-lock.js +5 -0
- package/src/tui-compiled/runtime/shared/458b934e92490331-mutate.js +2 -2
- package/src/tui-compiled/runtime/shared/4825e03209d6ae3e-with-lock.js +4 -0
- package/src/tui-compiled/runtime/shared/4f36d333d081d040-merge.js +21 -5
- package/src/tui-compiled/runtime/shared/7211cfec06a298bc-atomic-write.js +21 -0
- package/src/tui-compiled/runtime/shared/905bffc0220f43d0-map.js +36 -1
- package/src/tui-compiled/runtime/shared/a09b6a533c11858f-tui-preferences.js +6 -4
- package/src/tui-compiled/runtime/shared/c14988621dc60a75-port-file.js +30 -8
- package/src/tui-compiled/runtime/shared/c64d7afbc14ca125-sidebar-file.js +17 -8
- package/src/tui-compiled/runtime/shared/c852a15cfe489d12-pool.js +3 -0
- package/src/tui-compiled/runtime/shared/cd94316a888b0a8e-schema.js +3 -2
- package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +2 -2
- package/src/tui-compiled/runtime/shared/d7dcba96fea1685c-engine.js +126 -11
- package/src/tui-compiled/runtime/shared/e8cd4929507508d8-refresh-file-lock.js +254 -37
- package/src/tui-compiled/runtime/shared/ec971142548df90e-index.js +1 -0
- package/src/tui-compiled/runtime/shared/fbcdcd0f925e74c0-rpc-client.js +15 -1
- package/dist/index-8kj7sa1p.js +0 -12
- package/dist/index-b3q3j39x.js +0 -2
- package/dist/index-bmd7x5aq.js +0 -2
- package/dist/index-ff1jy44a.js +0 -222
- package/dist/index-jyr8y8k4.js +0 -7
- package/dist/index-rv08jmd4.js +0 -2
- package/dist/index-sjx8t1nr.js +0 -2
- package/dist/token-util-wdza526f.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The one quota wording every surface shows: the command menu's dialogs on
|
|
2
2
|
// OpenCode and Pi, the terminal auth menu, and the plugins' sidebars. Each of
|
|
3
|
-
// them calls `formatQuota`
|
|
4
|
-
//
|
|
3
|
+
// them calls `formatQuota`, `quotaTextParts`, or `quotaWindowParts`, so the
|
|
4
|
+
// wording cannot drift between plugins or between a dialog and a sidebar.
|
|
5
5
|
//
|
|
6
6
|
// The rules: a window is named by its length (`5h`, `7d`), or, for a window
|
|
7
7
|
// that binds only one model family, by the label the plugin stored it under.
|
|
@@ -55,17 +55,20 @@ export function quotaWindowName(limit) {
|
|
|
55
55
|
return limit.label;
|
|
56
56
|
return lengthName(limit.windowMinutes);
|
|
57
57
|
}
|
|
58
|
+
function clampPercent(remaining) {
|
|
59
|
+
return Math.min(100, Math.max(0, remaining));
|
|
60
|
+
}
|
|
58
61
|
function percentLeft(remaining) {
|
|
59
|
-
return `${Math.round(
|
|
62
|
+
return `${Math.round(clampPercent(remaining))}% left`;
|
|
60
63
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
function resetDetails(resetsAt, checkedAt, now) {
|
|
65
|
+
const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt);
|
|
66
|
+
return {
|
|
67
|
+
...(Number.isFinite(at) && at > now
|
|
68
|
+
? { resetIn: shortSpan(at - now) }
|
|
69
|
+
: {}),
|
|
70
|
+
resetSinceReading: Number.isFinite(at) && at > checkedAt && at <= now,
|
|
71
|
+
};
|
|
69
72
|
}
|
|
70
73
|
/** Shortest window first, so `5h` reads before `7d`; unknown lengths last. */
|
|
71
74
|
function displayOrder(left, right) {
|
|
@@ -85,42 +88,70 @@ function displayOrder(left, right) {
|
|
|
85
88
|
return left.label < right.label ? -1 : left.label > right.label ? 1 : 0;
|
|
86
89
|
}
|
|
87
90
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
92
|
-
* one line per window (the terminal auth menu) uses these directly.
|
|
91
|
+
* Structured quota wording for layouts that need one row per window. The
|
|
92
|
+
* windows use the same filtering and order as `quotaTextParts`; future resets
|
|
93
|
+
* and the optional age keep the shared formatter's wording.
|
|
93
94
|
*/
|
|
94
|
-
export function
|
|
95
|
+
export function quotaWindowParts(quota, options) {
|
|
95
96
|
if (!quota)
|
|
96
|
-
return [];
|
|
97
|
+
return { windows: [] };
|
|
97
98
|
const { now } = options;
|
|
98
|
-
const full = (options.form ?? 'full') === 'full';
|
|
99
|
-
const parts = [];
|
|
100
|
-
const times = [];
|
|
101
99
|
const readings = quota.limits
|
|
102
100
|
.filter((limit) => limit.kind === 'reading' && limit.remainingPercent !== undefined)
|
|
103
101
|
.sort(displayOrder);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
const windows = readings.map((limit) => {
|
|
103
|
+
const remainingPercent = clampPercent(limit.remainingPercent ?? 0);
|
|
104
|
+
return {
|
|
105
|
+
kind: 'window',
|
|
106
|
+
name: quotaWindowName(limit),
|
|
107
|
+
figure: percentLeft(limit.remainingPercent ?? 0),
|
|
108
|
+
remainingPercent,
|
|
109
|
+
checkedAt: limit.checkedAt,
|
|
110
|
+
...resetDetails(limit.resetsAt, limit.checkedAt, now),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
108
113
|
const budget = quota.budget;
|
|
109
114
|
if (budget) {
|
|
110
|
-
|
|
115
|
+
const remainingPercent = budget.remainingPercent;
|
|
111
116
|
const figure = budget.reached
|
|
112
117
|
? 'credits spent'
|
|
113
|
-
:
|
|
114
|
-
? `credits ${percentLeft(
|
|
118
|
+
: remainingPercent !== undefined
|
|
119
|
+
? `credits ${percentLeft(remainingPercent)}`
|
|
115
120
|
: 'credits available';
|
|
116
|
-
|
|
121
|
+
windows.push({
|
|
122
|
+
kind: 'budget',
|
|
123
|
+
name: 'credits',
|
|
124
|
+
figure,
|
|
125
|
+
...(remainingPercent === undefined
|
|
126
|
+
? {}
|
|
127
|
+
: { remainingPercent: clampPercent(remainingPercent) }),
|
|
128
|
+
checkedAt: budget.checkedAt,
|
|
129
|
+
...resetDetails(budget.resetsAt, budget.checkedAt, now),
|
|
130
|
+
});
|
|
117
131
|
}
|
|
118
|
-
if (
|
|
119
|
-
const age = now - Math.min(...
|
|
120
|
-
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS))
|
|
121
|
-
|
|
132
|
+
if (windows.length > 0) {
|
|
133
|
+
const age = now - Math.min(...windows.map((part) => part.checkedAt));
|
|
134
|
+
if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
|
|
135
|
+
return { windows, age: `checked ${shortSpan(age)} ago` };
|
|
136
|
+
}
|
|
122
137
|
}
|
|
123
|
-
return
|
|
138
|
+
return { windows };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The pieces of a quota text, in order: one per reported window
|
|
142
|
+
* (`5h 58% left, resets 2h`), then the credit budget when there is one, then
|
|
143
|
+
* `checked 3h ago` when the oldest piece shown is stale. Empty when nothing
|
|
144
|
+
* was reported; `formatQuota` turns that into a sentence. A caller printing
|
|
145
|
+
* one line per window (the terminal auth menu) uses these directly.
|
|
146
|
+
*/
|
|
147
|
+
export function quotaTextParts(quota, options) {
|
|
148
|
+
const parts = quotaWindowParts(quota, options);
|
|
149
|
+
const full = (options.form ?? 'full') === 'full';
|
|
150
|
+
const text = parts.windows.map((part) => {
|
|
151
|
+
const reset = full && part.resetIn ? `, resets ${part.resetIn}` : '';
|
|
152
|
+
return `${part.kind === 'budget' ? '' : `${part.name} `}${part.figure}${reset}`;
|
|
153
|
+
});
|
|
154
|
+
return parts.age === undefined ? text : [...text, parts.age];
|
|
124
155
|
}
|
|
125
156
|
/**
|
|
126
157
|
* A row's quota as one line: `5h 58% left · 7d 90% left` in the compact
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isQuotaMap } from "./905bffc0220f43d0-map.js";
|
|
1
|
+
import { decodeQuotaMap, isQuotaMap } from "./905bffc0220f43d0-map.js";
|
|
2
2
|
import { mergeQuotaObservation } from "./4f36d333d081d040-merge.js";
|
|
3
3
|
/**
|
|
4
4
|
* The quota codec the account-pool store (the `/store` subpath) takes when it
|
|
@@ -8,5 +8,6 @@ import { mergeQuotaObservation } from "./4f36d333d081d040-merge.js";
|
|
|
8
8
|
*/
|
|
9
9
|
export const quotaCodec = Object.freeze({
|
|
10
10
|
validate: isQuotaMap,
|
|
11
|
+
decode: decodeQuotaMap,
|
|
11
12
|
merge: mergeQuotaObservation,
|
|
12
13
|
});
|
|
@@ -2,8 +2,13 @@ import { readFileSync, realpathSync, statSync, watch, } from "node:fs";
|
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { clearTimeout, setTimeout } from "node:timers";
|
|
5
|
+
import { PrivateFileError, readPrivateText, windowsPrivateFiles, } from "./375d9cca2def037d-private-files.js";
|
|
5
6
|
function canonicalPath(file) {
|
|
6
7
|
const path = resolve(file);
|
|
8
|
+
// Leave reparse points in the path for private-files' ancestor walk to reject;
|
|
9
|
+
// realpath would follow them before the security backend saw the original path.
|
|
10
|
+
if (windowsPrivateFiles)
|
|
11
|
+
return path;
|
|
7
12
|
try {
|
|
8
13
|
return realpathSync(path);
|
|
9
14
|
}
|
|
@@ -34,7 +39,9 @@ export function watchTuiPreferences(file, onChange, options = {}) {
|
|
|
34
39
|
if (shared)
|
|
35
40
|
return shared.subscribe(onChange);
|
|
36
41
|
const name = basename(path);
|
|
37
|
-
const read =
|
|
42
|
+
const read = windowsPrivateFiles
|
|
43
|
+
? (file) => readPrivateText(file)
|
|
44
|
+
: (options.fs?.readFile ?? ((file) => readFile(file, 'utf8')));
|
|
38
45
|
const probe = options.fs?.stat ?? stat;
|
|
39
46
|
let timer = null;
|
|
40
47
|
let pollTimer = null;
|
|
@@ -43,14 +50,15 @@ export function watchTuiPreferences(file, onChange, options = {}) {
|
|
|
43
50
|
// Seed synchronously so an immediate write cannot be absorbed as the baseline.
|
|
44
51
|
// The metadata is recorded only together with contents that were read, as in
|
|
45
52
|
// the probe below: if the first read fails, the probe keeps trying.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
if (!windowsPrivateFiles)
|
|
54
|
+
try {
|
|
55
|
+
const metadata = identity((options.fs?.statSync ?? statSync)(path));
|
|
56
|
+
lastSeen = (options.fs?.readFileSync ?? ((file) => readFileSync(file, 'utf8')))(path);
|
|
57
|
+
lastMetadata = metadata;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Missing or unreadable files are picked up by the probe later.
|
|
61
|
+
}
|
|
54
62
|
let disposed = false;
|
|
55
63
|
const subscribers = new Set();
|
|
56
64
|
let checking = Promise.resolve();
|
|
@@ -68,7 +76,15 @@ export function watchTuiPreferences(file, onChange, options = {}) {
|
|
|
68
76
|
lastMetadata = null;
|
|
69
77
|
return;
|
|
70
78
|
}
|
|
71
|
-
const text = await read(path).catch(() =>
|
|
79
|
+
const text = await read(path).catch((error) => {
|
|
80
|
+
if (error instanceof PrivateFileError && error.code !== 'ENOENT') {
|
|
81
|
+
if (options.onError)
|
|
82
|
+
options.onError(error);
|
|
83
|
+
else
|
|
84
|
+
process.emitWarning(error.message, error.name);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
});
|
|
72
88
|
if (disposed)
|
|
73
89
|
return;
|
|
74
90
|
// Record the metadata only once its contents were read. A failed read
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
export const windowsPrivateFiles = process.platform === 'win32';
|
|
5
|
+
const migrationNotices = new Set();
|
|
6
|
+
function ioCode(detail) {
|
|
7
|
+
// Native errors retain their Win32 code. Only demonstrated absence/exclusive
|
|
8
|
+
// collisions are normalized; access/sharing failures never become lost races.
|
|
9
|
+
if (detail?.reason === 'io-error') {
|
|
10
|
+
if (detail.win32Code === 2 || detail.win32Code === 3)
|
|
11
|
+
return 'ENOENT';
|
|
12
|
+
if (detail.win32Code === 80 || detail.win32Code === 183)
|
|
13
|
+
return 'EEXIST';
|
|
14
|
+
}
|
|
15
|
+
return detail?.code ?? 'ERR_PRIVATE_FILE';
|
|
16
|
+
}
|
|
17
|
+
// Production loads this package only on Windows; POSIX keeps its own directory policy.
|
|
18
|
+
let backend;
|
|
19
|
+
/** Tests inject a backend here; production always imports the private-files package. */
|
|
20
|
+
export function setPrivateFilesBackendForTest(api) {
|
|
21
|
+
backend = api ? Promise.resolve(api) : undefined;
|
|
22
|
+
}
|
|
23
|
+
function loadBackend() {
|
|
24
|
+
backend ??= import("@cortexkit/private-files");
|
|
25
|
+
return backend;
|
|
26
|
+
}
|
|
27
|
+
export class PrivateFileError extends Error {
|
|
28
|
+
path;
|
|
29
|
+
stage;
|
|
30
|
+
code;
|
|
31
|
+
win32Code;
|
|
32
|
+
reason;
|
|
33
|
+
published = false;
|
|
34
|
+
durabilityConfirmed = false;
|
|
35
|
+
fileDurable = false;
|
|
36
|
+
attempts = 0;
|
|
37
|
+
elapsedMs = 0;
|
|
38
|
+
orphanPath;
|
|
39
|
+
constructor(path, stage, cause) {
|
|
40
|
+
const detail = cause;
|
|
41
|
+
super(`Private file refused at ${stage}: ${path} (${detail?.reason ?? detail?.code ?? 'io-error'}). Use a safe parent directory. Repair only a named secret file in safe ancestry whose owner the published policy trusts, without changing its owner or any existing directory. Previously exposed credentials may need rotation. Windows requires the private-files native prebuild and an ACL-persisting local filesystem such as NTFS.`, { cause });
|
|
42
|
+
this.path = path;
|
|
43
|
+
this.stage = stage;
|
|
44
|
+
this.name = 'PrivateFileError';
|
|
45
|
+
this.code = ioCode(detail);
|
|
46
|
+
this.win32Code = detail?.win32Code;
|
|
47
|
+
this.reason = detail?.reason;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function privateOperation(path, stage, operation) {
|
|
51
|
+
try {
|
|
52
|
+
return await operation();
|
|
53
|
+
}
|
|
54
|
+
catch (cause) {
|
|
55
|
+
if (cause instanceof PrivateFileError)
|
|
56
|
+
throw cause;
|
|
57
|
+
throw new PrivateFileError(path, stage, cause);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Tests inject metadata into safe-parent checks, including checks deep inside
|
|
61
|
+
// callers that take no policy options. Production never sets this.
|
|
62
|
+
let safeParentOptionsForTest;
|
|
63
|
+
/** Tests only: policy options used by safe-parent checks that pass none. */
|
|
64
|
+
export function setSafeParentOptionsForTest(options) {
|
|
65
|
+
safeParentOptionsForTest = options;
|
|
66
|
+
}
|
|
67
|
+
/** Existing containing directories use the shared entry-integrity policy, not secret-file permissions. */
|
|
68
|
+
export async function verifySafeDirectory(path, options) {
|
|
69
|
+
path = resolve(path);
|
|
70
|
+
options ??= safeParentOptionsForTest;
|
|
71
|
+
await privateOperation(path, 'safe-parent', async () => {
|
|
72
|
+
const result = await (await loadBackend()).verifySafeParent(path, options);
|
|
73
|
+
if (result.ok === false) {
|
|
74
|
+
// Preserve an absent parent's ENOENT so callers can create it privately.
|
|
75
|
+
if (result.reason === 'io-error' || result.reason === 'wrong-kind')
|
|
76
|
+
await lstat(path);
|
|
77
|
+
throw new PrivateFileError(result.level ?? result.path, 'safe-parent', {
|
|
78
|
+
...result,
|
|
79
|
+
code: 'ERR_PRIVATE_FILES_SECURITY',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Repair only the specific owned secret file supplied by a caller, never an existing directory tree. */
|
|
85
|
+
export async function verifyPrivate(path, kind, migrate = false) {
|
|
86
|
+
path = resolve(path);
|
|
87
|
+
if (kind === 'directory')
|
|
88
|
+
return verifySafeDirectory(path);
|
|
89
|
+
await privateOperation(path, 'verify', async () => {
|
|
90
|
+
const api = await loadBackend();
|
|
91
|
+
const result = await api.verifyOwnerOnly(path);
|
|
92
|
+
let repaired = false;
|
|
93
|
+
if (!result.ok) {
|
|
94
|
+
const { reason } = result.refusal;
|
|
95
|
+
// The result API intentionally omits native IO codes. Use the throwing
|
|
96
|
+
// API to preserve absence/collision details, never infer them from text.
|
|
97
|
+
if (reason === 'io-error')
|
|
98
|
+
await api.verifyPrivatePath(path, kind);
|
|
99
|
+
// DACL replacement repairs permissions only, never ownership or unsafe ancestry.
|
|
100
|
+
if (migrate &&
|
|
101
|
+
[
|
|
102
|
+
api.EXTRA_ACE,
|
|
103
|
+
api.NOT_PROTECTED,
|
|
104
|
+
api.NULL_DACL,
|
|
105
|
+
api.EMPTY_DACL,
|
|
106
|
+
].includes(reason)) {
|
|
107
|
+
await verifySafeDirectory(dirname(path));
|
|
108
|
+
await api.securePrivatePath(path, kind);
|
|
109
|
+
repaired = true;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
throw Object.assign(new Error('Owner-only verification refused'), {
|
|
113
|
+
reason,
|
|
114
|
+
code: 'ERR_PRIVATE_FILES_SECURITY',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Verify the expected object kind and its permissions by handle after repair.
|
|
119
|
+
await api.verifyPrivatePath(path, kind);
|
|
120
|
+
if (repaired && !migrationNotices.has(path)) {
|
|
121
|
+
migrationNotices.add(path);
|
|
122
|
+
process.emitWarning(`Private permissions tightened: ${path}. This object may have been readable before; rotate credentials if needed.`, 'PrivateFileMigration');
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Create missing components privately; inspect existing ancestors without modifying them. */
|
|
127
|
+
export async function ensurePrivateDirectory(path, _migrate = false, options) {
|
|
128
|
+
path = resolve(path);
|
|
129
|
+
await privateOperation(path, 'directory', async () => {
|
|
130
|
+
const api = await loadBackend();
|
|
131
|
+
try {
|
|
132
|
+
await lstat(path);
|
|
133
|
+
return await verifySafeDirectory(path, options);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (ioCode(error) !== 'ENOENT')
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
const parent = dirname(path);
|
|
140
|
+
if (parent === path)
|
|
141
|
+
throw Object.assign(new Error('Missing root'), { code: 'ENOENT' });
|
|
142
|
+
await ensurePrivateDirectory(parent, false, options);
|
|
143
|
+
try {
|
|
144
|
+
await api.createOwnerOnly(path, { kind: 'dir' });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (ioCode(error) !== 'EEXIST')
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
await verifySafeDirectory(path, options);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export async function readPrivateText(path, migrate = false) {
|
|
154
|
+
if (!windowsPrivateFiles)
|
|
155
|
+
return readFile(path, 'utf8');
|
|
156
|
+
return (await readPrivateBuffer(path, migrate)).toString('utf8');
|
|
157
|
+
}
|
|
158
|
+
export async function readPrivateBuffer(path, migrate = false) {
|
|
159
|
+
if (!windowsPrivateFiles)
|
|
160
|
+
return readFile(path);
|
|
161
|
+
path = resolve(path);
|
|
162
|
+
if (migrate) {
|
|
163
|
+
await verifySafeDirectory(dirname(path));
|
|
164
|
+
await verifyPrivate(path, 'file', true);
|
|
165
|
+
}
|
|
166
|
+
return privateOperation(path, 'read', async () => (await loadBackend()).readOwnerOnly(path));
|
|
167
|
+
}
|
|
168
|
+
export async function createPrivateBytes(path, bytes) {
|
|
169
|
+
await verifySafeDirectory(dirname(resolve(path)));
|
|
170
|
+
await privateOperation(path, 'create', async () => {
|
|
171
|
+
// The backend verifies the creation handle while empty, before consuming bytes.
|
|
172
|
+
await (await loadBackend()).createPrivateFile(resolve(path), bytes);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export async function createPrivateDirectoryExclusive(path) {
|
|
176
|
+
await verifySafeDirectory(dirname(resolve(path)));
|
|
177
|
+
await privateOperation(path, 'directory-create', async () => {
|
|
178
|
+
await (await loadBackend()).createOwnerOnly(resolve(path), { kind: 'dir' });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Retry Windows rename sharing/access errors within a budget; they never prove lease contention. */
|
|
182
|
+
export async function replaceWindowsFile(from, to, options = {}) {
|
|
183
|
+
from = resolve(from);
|
|
184
|
+
to = resolve(to);
|
|
185
|
+
const now = options.now ?? (() => performance.now());
|
|
186
|
+
const start = now();
|
|
187
|
+
const deadline = Math.min(start + 500, options.deadline ?? Infinity);
|
|
188
|
+
const delays = [0, 10, 20, 40, 80, 100];
|
|
189
|
+
let original;
|
|
190
|
+
let attempts = 0;
|
|
191
|
+
for (const delay of delays) {
|
|
192
|
+
if (now() >= deadline)
|
|
193
|
+
break;
|
|
194
|
+
if (delay)
|
|
195
|
+
await (options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))))(Math.min(delay, Math.max(0, deadline - now())));
|
|
196
|
+
if (now() >= deadline)
|
|
197
|
+
break;
|
|
198
|
+
await options.beforeRename?.();
|
|
199
|
+
if (now() >= deadline)
|
|
200
|
+
break;
|
|
201
|
+
attempts++;
|
|
202
|
+
try {
|
|
203
|
+
await (options.rename ?? rename)(from, to);
|
|
204
|
+
return { attempts, elapsedMs: now() - start };
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
original ??= error;
|
|
208
|
+
const detail = error;
|
|
209
|
+
const candidate = detail.win32Code !== undefined
|
|
210
|
+
? detail.win32Code === 32 || detail.win32Code === 33
|
|
211
|
+
: ['EBUSY', 'EPERM', 'EACCES'].includes(detail.code ?? '') &&
|
|
212
|
+
!detail.reason;
|
|
213
|
+
if (!candidate)
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const error = new PrivateFileError(to, 'rename', original ??
|
|
218
|
+
Object.assign(new Error('Publication deadline expired'), {
|
|
219
|
+
code: 'ETIMEDOUT',
|
|
220
|
+
}));
|
|
221
|
+
error.attempts = attempts;
|
|
222
|
+
error.elapsedMs = now() - start;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
export async function writePrivateAtomic(path, bytes, options = {}) {
|
|
226
|
+
path = resolve(path);
|
|
227
|
+
await ensurePrivateDirectory(dirname(path));
|
|
228
|
+
try {
|
|
229
|
+
await verifyPrivate(path, 'file', true);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error.code !== 'ENOENT')
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
const stage = resolve(options.stagePath ?? `${path}.${randomUUID()}.tmp`);
|
|
236
|
+
if (dirname(stage) !== dirname(path))
|
|
237
|
+
throw new PrivateFileError(path, 'stage-directory', { code: 'EXDEV' });
|
|
238
|
+
let created = false;
|
|
239
|
+
let published = false;
|
|
240
|
+
let outcome = { attempts: 0, elapsedMs: 0 };
|
|
241
|
+
let stageName = 'stage-create';
|
|
242
|
+
const reportOutcome = (error) => {
|
|
243
|
+
try {
|
|
244
|
+
options.onOutcome?.({
|
|
245
|
+
path,
|
|
246
|
+
stage: error?.stage ?? 'published',
|
|
247
|
+
published,
|
|
248
|
+
fileDurable: published,
|
|
249
|
+
durabilityConfirmed: false,
|
|
250
|
+
attempts: error?.attempts ?? outcome.attempts,
|
|
251
|
+
elapsedMs: error?.elapsedMs ?? outcome.elapsedMs,
|
|
252
|
+
...(error && { code: error.code, win32Code: error.win32Code }),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Outcome observers cannot change whether publication succeeded.
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
try {
|
|
260
|
+
await createPrivateBytes(stage, bytes);
|
|
261
|
+
created = true;
|
|
262
|
+
stageName = 'before-rename';
|
|
263
|
+
outcome = await replaceWindowsFile(stage, path, options);
|
|
264
|
+
created = false;
|
|
265
|
+
published = true;
|
|
266
|
+
stageName = 'published-callback';
|
|
267
|
+
options.onRenamed?.();
|
|
268
|
+
stageName = 'published-verify';
|
|
269
|
+
await verifyPrivate(path, 'file');
|
|
270
|
+
// createPrivateFile flushes the file. Directory metadata durability has not
|
|
271
|
+
// been qualified on Windows, so never promise the POSIX directory fsync.
|
|
272
|
+
if (options.durable && options.durability !== 'file-only-on-windows')
|
|
273
|
+
throw new PrivateFileError(path, 'directory-sync-unsupported', {
|
|
274
|
+
code: 'ENOTSUP',
|
|
275
|
+
});
|
|
276
|
+
reportOutcome();
|
|
277
|
+
}
|
|
278
|
+
catch (cause) {
|
|
279
|
+
const error = cause instanceof PrivateFileError
|
|
280
|
+
? cause
|
|
281
|
+
: new PrivateFileError(path, stageName, cause);
|
|
282
|
+
error.published = published;
|
|
283
|
+
error.fileDurable = published;
|
|
284
|
+
if (published)
|
|
285
|
+
Object.assign(error, outcome);
|
|
286
|
+
if (created) {
|
|
287
|
+
try {
|
|
288
|
+
await unlink(stage);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
error.orphanPath = stage;
|
|
292
|
+
}
|
|
293
|
+
created = false;
|
|
294
|
+
}
|
|
295
|
+
reportOutcome(error);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
if (created)
|
|
300
|
+
await unlink(stage).catch(() => { });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// scope, orders the result the way the primitives expect, and carries each
|
|
7
7
|
// entry's (scope, label) so a decision names the limit it judged rather than
|
|
8
8
|
// the position it occupied.
|
|
9
|
-
import { ALL_SCOPE, } from "./905bffc0220f43d0-map.js";
|
|
9
|
+
import { ALL_SCOPE, readQuotaCounters, } from "./905bffc0220f43d0-map.js";
|
|
10
10
|
function project(entry) {
|
|
11
11
|
if (entry.kind === 'retired') {
|
|
12
12
|
return {
|
|
@@ -94,11 +94,13 @@ export function projectQuota(map, scope = ALL_SCOPE) {
|
|
|
94
94
|
? Math.min(...stamped.map((limit) => limit.checkedAt))
|
|
95
95
|
: undefined;
|
|
96
96
|
const budget = map?.budget?.kind === 'reading' ? projectBudget(map.budget) : undefined;
|
|
97
|
+
const counters = readQuotaCounters(map?.counters);
|
|
97
98
|
return {
|
|
98
99
|
scope,
|
|
99
100
|
limits,
|
|
100
101
|
...(checkedAt === undefined ? {} : { checkedAt }),
|
|
101
102
|
...(budget === undefined ? {} : { budget }),
|
|
103
|
+
...(counters === undefined ? {} : { counters }),
|
|
102
104
|
};
|
|
103
105
|
}
|
|
104
106
|
/**
|
|
@@ -45,6 +45,11 @@ export async function acquirePoolLock(spec, defaults, env) {
|
|
|
45
45
|
now: env.now,
|
|
46
46
|
renew: options.renew,
|
|
47
47
|
onContended: () => emit('contended'),
|
|
48
|
+
...(env.onRenewalTimer
|
|
49
|
+
? {
|
|
50
|
+
onRenewalTimer: (event) => env.onRenewalTimer?.({ name: spec.name, path: spec.path }, event),
|
|
51
|
+
}
|
|
52
|
+
: {}),
|
|
48
53
|
...(options.renewIntervalMs !== undefined
|
|
49
54
|
? { renewIntervalMs: options.renewIntervalMs }
|
|
50
55
|
: {}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
1
|
import { setTimeout as snapshotDelay } from "node:timers/promises";
|
|
3
2
|
import { AtomicSyncError, syncJsonFile, writeJsonAtomic, writeJsonAtomicTracked, } from "./7211cfec06a298bc-atomic-write.js";
|
|
3
|
+
import { readPrivateText, windowsPrivateFiles } from "./375d9cca2def037d-private-files.js";
|
|
4
4
|
import { LockContentionError, LockOwnershipError } from "./4825e03209d6ae3e-with-lock.js";
|
|
5
5
|
import { PoolOperationError, } from "./04a6357627de5ed0-errors.js";
|
|
6
6
|
import { callFailureHook } from "./9deae3c4da4fd63b-hooks.js";
|
|
@@ -10,7 +10,7 @@ import { CREDENTIAL_STAMP_KEY, classifyConfig, classifyState, ensureEntries, ent
|
|
|
10
10
|
import { completeTornRows, loadRows } from "./fd45e82f2f9c0a0d-torn.js";
|
|
11
11
|
async function readText(path) {
|
|
12
12
|
try {
|
|
13
|
-
return await
|
|
13
|
+
return await readPrivateText(path, windowsPrivateFiles);
|
|
14
14
|
}
|
|
15
15
|
catch (error) {
|
|
16
16
|
if (error.code === 'ENOENT')
|
|
@@ -26,6 +26,10 @@ export async function withLock(target, options, fn) {
|
|
|
26
26
|
name: options.name,
|
|
27
27
|
ttlMs: options.ttlMs,
|
|
28
28
|
renew: options.renew ?? false,
|
|
29
|
+
...(options.onStep ? { onStep: options.onStep } : {}),
|
|
30
|
+
...(options.onRenewalTimer
|
|
31
|
+
? { onRenewalTimer: options.onRenewalTimer }
|
|
32
|
+
: {}),
|
|
29
33
|
});
|
|
30
34
|
if (lock) {
|
|
31
35
|
try {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// no entry gets an absence record. A pair outside the coverage is never
|
|
12
12
|
// touched, so a header-shaped partial observation (which covers only what it
|
|
13
13
|
// carries) cannot erase a family limit or the budget.
|
|
14
|
-
import { ALL_SCOPE, entryTime,
|
|
14
|
+
import { ALL_SCOPE, decodeQuotaMap, entryTime, isQuotaCounters, limitKey, } from "./905bffc0220f43d0-map.js";
|
|
15
15
|
/** Thrown by `mergeQuotaObservation` when either argument is malformed. */
|
|
16
16
|
export class QuotaCodecError extends TypeError {
|
|
17
17
|
constructor(message) {
|
|
@@ -83,7 +83,8 @@ export function isQuotaObservation(value) {
|
|
|
83
83
|
}
|
|
84
84
|
if (!coverage.every(isObservedPair))
|
|
85
85
|
return false;
|
|
86
|
-
return value.budget === undefined || isObservedBudget(value.budget)
|
|
86
|
+
return ((value.budget === undefined || isObservedBudget(value.budget)) &&
|
|
87
|
+
(value.counters === undefined || isQuotaCounters(value.counters)));
|
|
87
88
|
}
|
|
88
89
|
function compareEntries(left, right) {
|
|
89
90
|
if (left.scope !== right.scope)
|
|
@@ -99,6 +100,16 @@ function mergeBudget(stored, observed, checkedAt) {
|
|
|
99
100
|
return stored;
|
|
100
101
|
return { ...observed, checkedAt };
|
|
101
102
|
}
|
|
103
|
+
function mergeCounters(stored, observed) {
|
|
104
|
+
const counters = new Map(Object.entries(stored ?? {}));
|
|
105
|
+
for (const [name, counter] of Object.entries(observed ?? {})) {
|
|
106
|
+
const existing = counters.get(name);
|
|
107
|
+
if (existing === undefined || counter.checkedAt >= existing.checkedAt) {
|
|
108
|
+
counters.set(name, counter);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return counters.size === 0 ? undefined : Object.fromEntries(counters);
|
|
112
|
+
}
|
|
102
113
|
/**
|
|
103
114
|
* Applies `observation` to `stored` (undefined for a row with no map yet) and
|
|
104
115
|
* returns the new map. Pure: neither argument is modified. Throws
|
|
@@ -106,13 +117,16 @@ function mergeBudget(stored, observed, checkedAt) {
|
|
|
106
117
|
* write rather than persisting a map it could not read back.
|
|
107
118
|
*/
|
|
108
119
|
export function mergeQuotaObservation(stored, observation) {
|
|
109
|
-
|
|
120
|
+
// Decode the stored map the way reads do, so a malformed counter that reads
|
|
121
|
+
// already hide is dropped here too instead of refusing every later write.
|
|
122
|
+
const decoded = stored === undefined ? undefined : decodeQuotaMap(stored);
|
|
123
|
+
if (stored !== undefined && decoded === undefined) {
|
|
110
124
|
throw new QuotaCodecError('stored quota map is malformed');
|
|
111
125
|
}
|
|
112
126
|
if (!isQuotaObservation(observation)) {
|
|
113
127
|
throw new QuotaCodecError('quota observation is malformed');
|
|
114
128
|
}
|
|
115
|
-
const base =
|
|
129
|
+
const base = decoded ?? { limits: [] };
|
|
116
130
|
const at = observation.checkedAt;
|
|
117
131
|
const entries = new Map();
|
|
118
132
|
for (const entry of base.limits) {
|
|
@@ -152,11 +166,13 @@ export function mergeQuotaObservation(stored, observation) {
|
|
|
152
166
|
: { scope, label: pair.label, kind: 'retired', retiredAt: at });
|
|
153
167
|
}
|
|
154
168
|
// Keys this module does not recognise are carried over untouched.
|
|
155
|
-
const { limits: _limits, budget: _budget, ...unknownKeys } = base;
|
|
169
|
+
const { limits: _limits, budget: _budget, counters: _counters, ...unknownKeys } = base;
|
|
156
170
|
const budget = mergeBudget(base.budget, observation.budget, at);
|
|
171
|
+
const counters = mergeCounters(base.counters, observation.counters);
|
|
157
172
|
return {
|
|
158
173
|
...unknownKeys,
|
|
159
174
|
limits: [...entries.values()].sort(compareEntries),
|
|
160
175
|
...(budget === undefined ? {} : { budget }),
|
|
176
|
+
...(counters === undefined ? {} : { counters }),
|
|
161
177
|
};
|
|
162
178
|
}
|