@catalyst-cloud/cli 0.14.6 → 0.14.7
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/CHANGELOG.md +5 -0
- package/README.md +5 -3
- package/dist/args.js +51 -13
- package/dist/cli.js +58 -10
- package/dist/config.js +16 -3
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +243 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +203 -0
- package/dist/events-cloud-tail.js +134 -0
- package/dist/events-cloud.js +500 -0
- package/dist/events.js +13 -4
- package/dist/oauth.js +125 -33
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +147 -37
- package/dist/onboard-runtime.js +104 -9
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +99 -11
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard.js +359 -27
- package/dist/owned-fetch.js +182 -0
- package/dist/query.js +77 -27
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +2 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +3 -3
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +5 -4
- package/skills/catalyst-onboard/references/local-sync.md +3 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +2 -2
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- package/vendor/settings/provenance.json +148 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { configPathFor, } from "./config.js";
|
|
2
|
+
import { personCacheScope } from "./daemon-cache.js";
|
|
3
|
+
import { ownedFetch } from "./owned-fetch.js";
|
|
4
|
+
import { fetchMe } from "./transport.js";
|
|
5
|
+
import { onboardFileSnapshot } from "./onboard-file-snapshot.js";
|
|
6
|
+
export class DaemonAuthorityError extends Error {
|
|
7
|
+
kind;
|
|
8
|
+
constructor(kind, code) {
|
|
9
|
+
super(code);
|
|
10
|
+
this.kind = kind;
|
|
11
|
+
this.name = "DaemonAuthorityError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const refused = (code) => new DaemonAuthorityError("authentication", code);
|
|
15
|
+
const changed = () => new DaemonAuthorityError("identity", "daemon_authority_changed");
|
|
16
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
17
|
+
? value
|
|
18
|
+
: null;
|
|
19
|
+
function currentConfig(ctx) {
|
|
20
|
+
let bytes;
|
|
21
|
+
try {
|
|
22
|
+
bytes = onboardFileSnapshot(configPathFor(ctx.home));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw refused("daemon_config_unverified");
|
|
26
|
+
}
|
|
27
|
+
if (bytes === null)
|
|
28
|
+
throw refused("daemon_login_required");
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(bytes);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw refused("daemon_config_unverified");
|
|
35
|
+
}
|
|
36
|
+
const config = object(parsed), user = object(config?.user);
|
|
37
|
+
if (!config ||
|
|
38
|
+
!user ||
|
|
39
|
+
typeof config.account !== "string" ||
|
|
40
|
+
typeof config.baseUrl !== "string" ||
|
|
41
|
+
typeof config.slug !== "string" ||
|
|
42
|
+
typeof config.name !== "string" ||
|
|
43
|
+
typeof config.joinedAt !== "string" ||
|
|
44
|
+
typeof config.lastSkillBundleVersion !== "string" ||
|
|
45
|
+
config.principal !== "service" ||
|
|
46
|
+
typeof user.id !== "string" ||
|
|
47
|
+
typeof user.label !== "string" ||
|
|
48
|
+
!(user.email === null || typeof user.email === "string") ||
|
|
49
|
+
!(user.linearUserId === null || typeof user.linearUserId === "string") ||
|
|
50
|
+
(user.role !== "owner" && user.role !== "admin" && user.role !== "member"))
|
|
51
|
+
throw refused("daemon_config_unverified");
|
|
52
|
+
let permissions = null;
|
|
53
|
+
if (config.permissions !== null) {
|
|
54
|
+
if (!Array.isArray(config.permissions) || config.permissions.length > 128)
|
|
55
|
+
throw refused("daemon_config_unverified");
|
|
56
|
+
permissions = [];
|
|
57
|
+
for (const permission of config.permissions) {
|
|
58
|
+
if (typeof permission !== "string")
|
|
59
|
+
throw refused("daemon_config_unverified");
|
|
60
|
+
permissions.push(permission);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let credential;
|
|
64
|
+
if (typeof config.key === "string" &&
|
|
65
|
+
config.key.length > 0 &&
|
|
66
|
+
config.auth === undefined)
|
|
67
|
+
credential = { key: config.key };
|
|
68
|
+
else {
|
|
69
|
+
const auth = object(config.auth);
|
|
70
|
+
if (config.key !== undefined ||
|
|
71
|
+
!auth ||
|
|
72
|
+
auth.kind !== "oauth" ||
|
|
73
|
+
typeof auth.sessionId !== "string" ||
|
|
74
|
+
typeof auth.accessToken !== "string" ||
|
|
75
|
+
!auth.accessToken.length ||
|
|
76
|
+
auth.accessToken.length > 8192 ||
|
|
77
|
+
typeof auth.refreshToken !== "string" ||
|
|
78
|
+
typeof auth.expiresAt !== "string")
|
|
79
|
+
throw refused("daemon_config_unverified");
|
|
80
|
+
credential = {
|
|
81
|
+
auth: {
|
|
82
|
+
kind: "oauth",
|
|
83
|
+
sessionId: auth.sessionId,
|
|
84
|
+
accessToken: auth.accessToken,
|
|
85
|
+
refreshToken: auth.refreshToken,
|
|
86
|
+
expiresAt: auth.expiresAt,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...credential,
|
|
92
|
+
account: config.account,
|
|
93
|
+
baseUrl: config.baseUrl,
|
|
94
|
+
slug: config.slug,
|
|
95
|
+
name: config.name,
|
|
96
|
+
principal: config.principal,
|
|
97
|
+
permissions,
|
|
98
|
+
joinedAt: config.joinedAt,
|
|
99
|
+
lastSkillBundleVersion: config.lastSkillBundleVersion,
|
|
100
|
+
user: {
|
|
101
|
+
id: user.id,
|
|
102
|
+
label: user.label,
|
|
103
|
+
email: user.email,
|
|
104
|
+
linearUserId: user.linearUserId,
|
|
105
|
+
role: user.role,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function currentToken(config, now) {
|
|
110
|
+
if (config.key && !config.auth)
|
|
111
|
+
return config.key;
|
|
112
|
+
if (!config.key &&
|
|
113
|
+
config.auth &&
|
|
114
|
+
Date.parse(config.auth.expiresAt) - now > 30_000)
|
|
115
|
+
return config.auth.accessToken;
|
|
116
|
+
// This reader never refreshes or writes the saved login. Renewal needs separately owned IO.
|
|
117
|
+
throw refused("daemon_login_refresh_required");
|
|
118
|
+
}
|
|
119
|
+
function scopeOf(config, me) {
|
|
120
|
+
let scope;
|
|
121
|
+
try {
|
|
122
|
+
scope = personCacheScope(config, me);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw changed();
|
|
126
|
+
}
|
|
127
|
+
if (scope.permissions !== null && !scope.permissions.includes("mirror:feed"))
|
|
128
|
+
throw refused("daemon_feed_permission_required");
|
|
129
|
+
return scope;
|
|
130
|
+
}
|
|
131
|
+
function sameScope(a, b) {
|
|
132
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
133
|
+
}
|
|
134
|
+
/** A read-only proof expires 30s after request ENTRY, not after a potentially slow response.
|
|
135
|
+
* The owner must stop peers at expiry and join this promise before releasing any writer lease.
|
|
136
|
+
* Cancellation can remain pending beyond the deadline until actual upstream IO acknowledges it. */
|
|
137
|
+
export async function readDaemonAuthority(ctx, parent) {
|
|
138
|
+
if (parent.aborted)
|
|
139
|
+
throw parent.reason;
|
|
140
|
+
const enteredAt = ctx.now().getTime();
|
|
141
|
+
if (!Number.isSafeInteger(enteredAt))
|
|
142
|
+
throw refused("daemon_clock_invalid");
|
|
143
|
+
const config = currentConfig(ctx);
|
|
144
|
+
// Saved claims only preflight destination/credential shape; they never establish live authority.
|
|
145
|
+
scopeOf(config, config);
|
|
146
|
+
const bearer = currentToken(config, enteredAt);
|
|
147
|
+
const stopped = new AbortController();
|
|
148
|
+
const signal = AbortSignal.any([parent, stopped.signal]);
|
|
149
|
+
const request = ownedFetch(ctx.fetch, signal, { maxBodyBytes: 1024 * 1024 });
|
|
150
|
+
const timer = setTimeout(() => stopped.abort(refused("daemon_authority_expired")), 30_000);
|
|
151
|
+
let primary;
|
|
152
|
+
let failed = false;
|
|
153
|
+
let proof;
|
|
154
|
+
try {
|
|
155
|
+
const me = await fetchMe(config.baseUrl, bearer, request.fetch, 30_000);
|
|
156
|
+
if (signal.aborted)
|
|
157
|
+
throw signal.reason;
|
|
158
|
+
const scope = scopeOf(config, me);
|
|
159
|
+
const expiresAt = enteredAt + 30_000;
|
|
160
|
+
const assertCurrent = () => {
|
|
161
|
+
if (parent.aborted)
|
|
162
|
+
throw parent.reason;
|
|
163
|
+
const current = currentConfig(ctx);
|
|
164
|
+
if (!sameScope(scope, scopeOf(current, me)))
|
|
165
|
+
throw changed();
|
|
166
|
+
const now = ctx.now().getTime();
|
|
167
|
+
currentToken(current, now);
|
|
168
|
+
// No saved-file read follows the final clock/parent boundary.
|
|
169
|
+
if (parent.aborted)
|
|
170
|
+
throw parent.reason;
|
|
171
|
+
if (!Number.isSafeInteger(now) || now < enteredAt || now >= expiresAt)
|
|
172
|
+
throw refused("daemon_authority_expired");
|
|
173
|
+
};
|
|
174
|
+
assertCurrent();
|
|
175
|
+
proof = Object.freeze({
|
|
176
|
+
scope,
|
|
177
|
+
expiresAt,
|
|
178
|
+
assertCurrent,
|
|
179
|
+
async getToken() {
|
|
180
|
+
assertCurrent();
|
|
181
|
+
const current = currentConfig(ctx);
|
|
182
|
+
if (!sameScope(scope, scopeOf(current, me)))
|
|
183
|
+
throw changed();
|
|
184
|
+
// A saved rotation is not proof about that new bearer. Use only the /me-verified token
|
|
185
|
+
// until a separately owned fresh read replaces this proof.
|
|
186
|
+
assertCurrent();
|
|
187
|
+
return bearer;
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
primary = signal.aborted
|
|
193
|
+
? signal.reason
|
|
194
|
+
: error instanceof DaemonAuthorityError
|
|
195
|
+
? error
|
|
196
|
+
: refused("daemon_authority_unavailable");
|
|
197
|
+
failed = true;
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
// The wrapper joins entered headers, readers and FIRST cancel; a timeout is never an ACK.
|
|
202
|
+
try {
|
|
203
|
+
await request.settle();
|
|
204
|
+
}
|
|
205
|
+
catch (cleanup) {
|
|
206
|
+
throw new AggregateError(failed ? [primary, cleanup] : [cleanup], "daemon_authority_cleanup_failed");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (failed)
|
|
210
|
+
throw primary;
|
|
211
|
+
if (!proof)
|
|
212
|
+
throw refused("daemon_authority_unverified");
|
|
213
|
+
// Even natural-response cleanup can be slow; publication checks the ORIGINAL expiry again.
|
|
214
|
+
proof.assertCurrent();
|
|
215
|
+
return proof;
|
|
216
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
const fail = () => new Error("daemon_cache_identity_unverified");
|
|
5
|
+
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
6
|
+
const own = (s) => process.getuid === undefined || s.uid === process.getuid();
|
|
7
|
+
const sameInode = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
8
|
+
const validId = (s) => typeof s === "string" &&
|
|
9
|
+
s.length > 0 &&
|
|
10
|
+
s.length <= 256 &&
|
|
11
|
+
!Array.from(s).some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
|
|
12
|
+
function permissions(value) {
|
|
13
|
+
if (value === null)
|
|
14
|
+
return null;
|
|
15
|
+
if (!Array.isArray(value) ||
|
|
16
|
+
value.length > 128 ||
|
|
17
|
+
value.some((v) => !validId(v)))
|
|
18
|
+
throw fail();
|
|
19
|
+
return [...new Set(value)].sort();
|
|
20
|
+
}
|
|
21
|
+
function httpsOrigin(value) {
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(value);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw fail();
|
|
28
|
+
}
|
|
29
|
+
if (url.protocol !== "https:" ||
|
|
30
|
+
url.username ||
|
|
31
|
+
url.password ||
|
|
32
|
+
url.search ||
|
|
33
|
+
url.hash ||
|
|
34
|
+
(url.pathname !== "/" && url.pathname !== ""))
|
|
35
|
+
throw fail();
|
|
36
|
+
return url.origin;
|
|
37
|
+
}
|
|
38
|
+
/** Only call after a fresh authenticated /me read. Config labels and token claims are not proof. */
|
|
39
|
+
export function personCacheScope(config, me) {
|
|
40
|
+
if (!me.user ||
|
|
41
|
+
!config.user ||
|
|
42
|
+
!validId(me.account) ||
|
|
43
|
+
!validId(me.user.id) ||
|
|
44
|
+
me.account !== config.account ||
|
|
45
|
+
me.user.id !== config.user.id ||
|
|
46
|
+
me.user.role !== config.user.role ||
|
|
47
|
+
!["owner", "admin", "member"].includes(me.user.role))
|
|
48
|
+
throw fail();
|
|
49
|
+
// /me does not carry project ACL revision or a personal replica dataset scope.
|
|
50
|
+
// Account-wide warm caches are therefore limited to current owners and admins.
|
|
51
|
+
if (me.user.role === "member")
|
|
52
|
+
throw new Error("daemon_cache_member_scope_unavailable");
|
|
53
|
+
const currentPermissions = permissions(me.permissions);
|
|
54
|
+
if (currentPermissions !== null &&
|
|
55
|
+
!currentPermissions.includes("mirror:read"))
|
|
56
|
+
throw fail();
|
|
57
|
+
if (JSON.stringify(currentPermissions) !==
|
|
58
|
+
JSON.stringify(permissions(config.permissions)))
|
|
59
|
+
throw fail();
|
|
60
|
+
if (Boolean(config.key) === Boolean(config.auth) ||
|
|
61
|
+
me.principal !== config.principal)
|
|
62
|
+
throw fail();
|
|
63
|
+
let credential;
|
|
64
|
+
if (config.key) {
|
|
65
|
+
if (me.principal !== "service" ||
|
|
66
|
+
!config.key.startsWith("ctc_user_") ||
|
|
67
|
+
config.key.length > 8192)
|
|
68
|
+
throw fail();
|
|
69
|
+
credential = "personal-key:" + digest(config.key);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Device-code JWTs are user-bearing service principals; session denotes browser cookies.
|
|
73
|
+
if (me.principal !== "service" ||
|
|
74
|
+
!config.auth ||
|
|
75
|
+
config.auth.kind !== "oauth" ||
|
|
76
|
+
!validId(config.auth.sessionId))
|
|
77
|
+
throw fail();
|
|
78
|
+
credential = "oauth-session:" + digest(config.auth.sessionId);
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
schema: 1,
|
|
82
|
+
account: me.account,
|
|
83
|
+
person: me.user.id,
|
|
84
|
+
origin: httpsOrigin(config.baseUrl),
|
|
85
|
+
role: me.user.role,
|
|
86
|
+
permissions: currentPermissions === null ? null : Object.freeze(currentPermissions),
|
|
87
|
+
credential,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function canonical(scope) {
|
|
91
|
+
if (scope.schema !== 1 ||
|
|
92
|
+
!validId(scope.account) ||
|
|
93
|
+
!validId(scope.person) ||
|
|
94
|
+
!["owner", "admin", "member"].includes(scope.role) ||
|
|
95
|
+
httpsOrigin(scope.origin) !== scope.origin ||
|
|
96
|
+
!/^(personal-key|oauth-session):[a-f0-9]{64}$/.test(scope.credential))
|
|
97
|
+
throw fail();
|
|
98
|
+
if (scope.role === "member")
|
|
99
|
+
throw new Error("daemon_cache_member_scope_unavailable");
|
|
100
|
+
const granted = permissions(scope.permissions);
|
|
101
|
+
if (granted !== null && !granted.includes("mirror:read"))
|
|
102
|
+
throw fail();
|
|
103
|
+
return (JSON.stringify({
|
|
104
|
+
schema: 1,
|
|
105
|
+
account: scope.account,
|
|
106
|
+
person: scope.person,
|
|
107
|
+
origin: scope.origin,
|
|
108
|
+
role: scope.role,
|
|
109
|
+
permissions: permissions(scope.permissions),
|
|
110
|
+
credential: scope.credential,
|
|
111
|
+
}) + "\n");
|
|
112
|
+
}
|
|
113
|
+
function stat(path) {
|
|
114
|
+
try {
|
|
115
|
+
return lstatSync(path);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
119
|
+
return null;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function checkDirectory(path, privateMode) {
|
|
124
|
+
const s = lstatSync(path);
|
|
125
|
+
if (!s.isDirectory() ||
|
|
126
|
+
s.isSymbolicLink() ||
|
|
127
|
+
!own(s) ||
|
|
128
|
+
(s.mode & (privateMode ? 0o077 : 0o022)) !== 0)
|
|
129
|
+
throw fail();
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
132
|
+
function readWitness(path) {
|
|
133
|
+
const before = lstatSync(path);
|
|
134
|
+
if (!before.isFile() ||
|
|
135
|
+
before.isSymbolicLink() ||
|
|
136
|
+
!own(before) ||
|
|
137
|
+
before.nlink !== 1 ||
|
|
138
|
+
(before.mode & 0o077) !== 0 ||
|
|
139
|
+
before.size > 65536)
|
|
140
|
+
throw fail();
|
|
141
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
142
|
+
try {
|
|
143
|
+
const opened = fstatSync(fd);
|
|
144
|
+
if (!sameInode(before, opened))
|
|
145
|
+
throw fail();
|
|
146
|
+
const buffer = Buffer.alloc(65537);
|
|
147
|
+
let count = 0;
|
|
148
|
+
while (count < buffer.length) {
|
|
149
|
+
const read = readSync(fd, buffer, count, buffer.length - count, null);
|
|
150
|
+
if (read === 0)
|
|
151
|
+
break;
|
|
152
|
+
count += read;
|
|
153
|
+
}
|
|
154
|
+
if (count > 65536)
|
|
155
|
+
throw fail();
|
|
156
|
+
const bytes = buffer.subarray(0, count).toString("utf8");
|
|
157
|
+
const after = lstatSync(path), ended = fstatSync(fd);
|
|
158
|
+
if (!sameInode(opened, after) ||
|
|
159
|
+
!sameInode(opened, ended) ||
|
|
160
|
+
opened.size !== Buffer.byteLength(bytes) ||
|
|
161
|
+
opened.mtimeMs !== after.mtimeMs ||
|
|
162
|
+
opened.mtimeMs !== ended.mtimeMs ||
|
|
163
|
+
opened.ctimeMs !== after.ctimeMs ||
|
|
164
|
+
(after.mode & 0o077) !== 0 ||
|
|
165
|
+
!own(after) ||
|
|
166
|
+
after.nlink !== 1)
|
|
167
|
+
throw fail();
|
|
168
|
+
return { bytes, stat: after };
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
closeSync(fd);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function checkCacheFiles(directory, pinned) {
|
|
175
|
+
let observed = 0;
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
const visit = (parent, depth) => {
|
|
178
|
+
if (depth > 16)
|
|
179
|
+
throw fail();
|
|
180
|
+
for (const name of readdirSync(parent)) {
|
|
181
|
+
if (++observed > 100000)
|
|
182
|
+
throw fail();
|
|
183
|
+
const path = join(parent, name), s = lstatSync(path);
|
|
184
|
+
// The enclosing scope directory is 0700. SDK-default 0644/0755 children are
|
|
185
|
+
// inaccessible to other users; writable aliases, links and special files are refused.
|
|
186
|
+
if (s.isSymbolicLink() || !own(s) || (s.mode & 0o022) !== 0)
|
|
187
|
+
throw fail();
|
|
188
|
+
if ((path === join(directory, "replica.db") && !s.isFile()) ||
|
|
189
|
+
(path === join(directory, "events") && !s.isDirectory()))
|
|
190
|
+
throw fail();
|
|
191
|
+
if (path === join(directory, "replica.db") ||
|
|
192
|
+
path === join(directory, "events")) {
|
|
193
|
+
const before = pinned.get(path);
|
|
194
|
+
if (before && !sameInode(before, s))
|
|
195
|
+
throw fail();
|
|
196
|
+
pinned.set(path, s);
|
|
197
|
+
}
|
|
198
|
+
seen.add(path);
|
|
199
|
+
if (s.isDirectory())
|
|
200
|
+
visit(path, depth + 1);
|
|
201
|
+
else if (!s.isFile() || s.nlink !== 1)
|
|
202
|
+
throw fail();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
visit(directory, 0);
|
|
206
|
+
for (const path of pinned.keys())
|
|
207
|
+
if (!seen.has(path))
|
|
208
|
+
throw fail();
|
|
209
|
+
}
|
|
210
|
+
/** SDK 0.13.1 has no IO-settlement acknowledgement. Refuse it before any cache or writer work. */
|
|
211
|
+
export function requireAwaitedReplicaShutdown(replicaClass) {
|
|
212
|
+
if (!("closeAndWait" in replicaClass.prototype) ||
|
|
213
|
+
typeof replicaClass.prototype.closeAndWait !== "function")
|
|
214
|
+
throw new Error("daemon_sdk_shutdown_unavailable");
|
|
215
|
+
}
|
|
216
|
+
/** Creates a new person-scoped cache; never adopts legacy cfg.replicaDb or tenant event paths.
|
|
217
|
+
* assertCurrent must synchronously reject expired or changed authority before every use. These
|
|
218
|
+
* inode checks protect cooperative local use; they do not promise a filesystem CAS. */
|
|
219
|
+
export function preparePersonCache(input) {
|
|
220
|
+
requireAwaitedReplicaShutdown(input.replicaClass);
|
|
221
|
+
input.assertCurrent();
|
|
222
|
+
const bytes = canonical(input.scope);
|
|
223
|
+
if (!isAbsolute(input.home))
|
|
224
|
+
throw fail();
|
|
225
|
+
const home = resolve(input.home);
|
|
226
|
+
const directory = join(home, ".config", "catalyst-cloud", "person-cache", "v1", digest(bytes));
|
|
227
|
+
const privateRoot = join(home, ".config", "catalyst-cloud", "person-cache");
|
|
228
|
+
const paths = [];
|
|
229
|
+
for (let path = directory; path !== dirname(path); path = dirname(path))
|
|
230
|
+
paths.unshift(path);
|
|
231
|
+
const pinned = new Map();
|
|
232
|
+
for (const path of paths) {
|
|
233
|
+
input.assertCurrent();
|
|
234
|
+
const withinHome = path === home || path.startsWith(home + sep);
|
|
235
|
+
const privateMode = path === privateRoot || path.startsWith(privateRoot + sep);
|
|
236
|
+
if (!stat(path)) {
|
|
237
|
+
if (!withinHome)
|
|
238
|
+
throw fail();
|
|
239
|
+
mkdirSync(path, { mode: 0o700 });
|
|
240
|
+
}
|
|
241
|
+
// System ancestors may belong to root; they must still be real directories without links.
|
|
242
|
+
if (!withinHome) {
|
|
243
|
+
const s = lstatSync(path);
|
|
244
|
+
const trustedSticky = s.uid === 0 && (s.mode & 0o1000) !== 0;
|
|
245
|
+
if (!s.isDirectory() ||
|
|
246
|
+
s.isSymbolicLink() ||
|
|
247
|
+
(!own(s) && s.uid !== 0) ||
|
|
248
|
+
((s.mode & 0o022) !== 0 && !trustedSticky))
|
|
249
|
+
throw fail();
|
|
250
|
+
}
|
|
251
|
+
else
|
|
252
|
+
pinned.set(path, checkDirectory(path, privateMode));
|
|
253
|
+
}
|
|
254
|
+
const witnessPath = join(directory, "identity.json");
|
|
255
|
+
if (!stat(witnessPath)) {
|
|
256
|
+
if (readdirSync(directory).length !== 0)
|
|
257
|
+
throw fail();
|
|
258
|
+
input.assertCurrent();
|
|
259
|
+
const fd = openSync(witnessPath, constants.O_WRONLY |
|
|
260
|
+
constants.O_CREAT |
|
|
261
|
+
constants.O_EXCL |
|
|
262
|
+
constants.O_NOFOLLOW, 0o600);
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(fd, bytes);
|
|
265
|
+
fsyncSync(fd);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
closeSync(fd);
|
|
269
|
+
}
|
|
270
|
+
const directoryFd = openSync(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
271
|
+
try {
|
|
272
|
+
fsyncSync(directoryFd);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
closeSync(directoryFd);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const witness = readWitness(witnessPath);
|
|
279
|
+
if (witness.bytes !== bytes)
|
|
280
|
+
throw fail();
|
|
281
|
+
const cacheInodes = new Map();
|
|
282
|
+
const assertWitness = () => {
|
|
283
|
+
input.assertCurrent();
|
|
284
|
+
for (const [path, original] of pinned) {
|
|
285
|
+
const privateMode = path === privateRoot || path.startsWith(privateRoot + sep);
|
|
286
|
+
if (!sameInode(original, checkDirectory(path, privateMode)))
|
|
287
|
+
throw fail();
|
|
288
|
+
}
|
|
289
|
+
const current = readWitness(witnessPath);
|
|
290
|
+
if (!sameInode(witness.stat, current.stat) || current.bytes !== bytes)
|
|
291
|
+
throw fail();
|
|
292
|
+
};
|
|
293
|
+
const assertOwnedPaths = () => {
|
|
294
|
+
assertWitness();
|
|
295
|
+
for (const [path, directoryPath] of [
|
|
296
|
+
[join(directory, "replica.db"), false],
|
|
297
|
+
[join(directory, "events"), true],
|
|
298
|
+
]) {
|
|
299
|
+
let stat;
|
|
300
|
+
try {
|
|
301
|
+
stat = lstatSync(path);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code === "ENOENT" &&
|
|
305
|
+
!cacheInodes.has(path))
|
|
306
|
+
continue;
|
|
307
|
+
throw fail();
|
|
308
|
+
}
|
|
309
|
+
if (stat.isSymbolicLink() ||
|
|
310
|
+
!own(stat) ||
|
|
311
|
+
(stat.mode & 0o022) !== 0 ||
|
|
312
|
+
(directoryPath
|
|
313
|
+
? !stat.isDirectory()
|
|
314
|
+
: !stat.isFile() || stat.nlink !== 1))
|
|
315
|
+
throw fail();
|
|
316
|
+
const before = cacheInodes.get(path);
|
|
317
|
+
if (before && !sameInode(before, stat))
|
|
318
|
+
throw fail();
|
|
319
|
+
cacheInodes.set(path, stat);
|
|
320
|
+
}
|
|
321
|
+
// SQLite owns these transient leaves and may remove/recreate them at checkpoint/close.
|
|
322
|
+
// Reject persistent aliases before the next SQL entry without pinning or deleting them.
|
|
323
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
|
324
|
+
const leaf = stat(join(directory, "replica.db" + suffix));
|
|
325
|
+
if (leaf &&
|
|
326
|
+
(!leaf.isFile() ||
|
|
327
|
+
leaf.isSymbolicLink() ||
|
|
328
|
+
!own(leaf) ||
|
|
329
|
+
leaf.nlink !== 1 ||
|
|
330
|
+
(leaf.mode & 0o022) !== 0))
|
|
331
|
+
throw fail();
|
|
332
|
+
}
|
|
333
|
+
input.assertCurrent();
|
|
334
|
+
};
|
|
335
|
+
const assertBound = () => {
|
|
336
|
+
assertWitness();
|
|
337
|
+
checkCacheFiles(directory, cacheInodes);
|
|
338
|
+
input.assertCurrent();
|
|
339
|
+
};
|
|
340
|
+
assertBound();
|
|
341
|
+
return {
|
|
342
|
+
directory,
|
|
343
|
+
replicaDb: join(directory, "replica.db"),
|
|
344
|
+
eventDirectory: join(directory, "events"),
|
|
345
|
+
assertBound,
|
|
346
|
+
assertOwnedPaths,
|
|
347
|
+
};
|
|
348
|
+
}
|