@h-rig/server 0.0.6-alpha.1 → 0.0.6-alpha.11

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.
@@ -0,0 +1,107 @@
1
+ // @bun
2
+ // packages/server/src/server-helpers/github-api-session-index.ts
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { dirname as dirname2, resolve as resolve2 } from "path";
5
+
6
+ // packages/server/src/server-helpers/github-user-namespace.ts
7
+ import { dirname, isAbsolute, relative, resolve } from "path";
8
+ function cleanString(value) {
9
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
10
+ }
11
+ function resolveRemoteUserNamespacesRoot(projectRoot) {
12
+ const explicitRoot = cleanString(process.env.RIG_REMOTE_USER_NAMESPACE_ROOT);
13
+ if (explicitRoot)
14
+ return resolve(explicitRoot);
15
+ const stateDir = cleanString(process.env.RIG_STATE_DIR);
16
+ if (stateDir)
17
+ return resolve(dirname(resolve(stateDir)), "users");
18
+ return resolve(projectRoot, ".rig", "users");
19
+ }
20
+
21
+ // packages/server/src/server-helpers/github-api-session-index.ts
22
+ function cleanString2(value) {
23
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
24
+ }
25
+ function resolveGitHubApiSessionIndexFile(projectRoot) {
26
+ return resolve2(resolveRemoteUserNamespacesRoot(projectRoot), ".api-sessions.json");
27
+ }
28
+ function parseEntry(value) {
29
+ if (!value || typeof value !== "object" || Array.isArray(value))
30
+ return null;
31
+ const record = value;
32
+ const token = cleanString2(record.token);
33
+ const namespaceKey = cleanString2(record.namespaceKey);
34
+ const namespaceRoot = cleanString2(record.namespaceRoot);
35
+ const authStateFile = cleanString2(record.authStateFile);
36
+ const checkoutBaseDir = cleanString2(record.checkoutBaseDir);
37
+ const snapshotBaseDir = cleanString2(record.snapshotBaseDir);
38
+ const createdAt = cleanString2(record.createdAt);
39
+ if (!token || !namespaceKey || !namespaceRoot || !authStateFile || !checkoutBaseDir || !snapshotBaseDir || !createdAt)
40
+ return null;
41
+ return {
42
+ token,
43
+ namespaceKey,
44
+ namespaceRoot,
45
+ authStateFile,
46
+ checkoutBaseDir,
47
+ snapshotBaseDir,
48
+ createdAt,
49
+ login: cleanString2(record.login),
50
+ userId: cleanString2(record.userId),
51
+ selectedRepo: cleanString2(record.selectedRepo)
52
+ };
53
+ }
54
+ function readIndex(indexFile) {
55
+ if (!existsSync(indexFile))
56
+ return [];
57
+ try {
58
+ const parsed = JSON.parse(readFileSync(indexFile, "utf8"));
59
+ return Array.isArray(parsed.sessions) ? parsed.sessions.flatMap((entry) => {
60
+ const parsedEntry = parseEntry(entry);
61
+ return parsedEntry ? [parsedEntry] : [];
62
+ }) : [];
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+ function writeIndex(indexFile, sessions) {
68
+ mkdirSync(dirname2(indexFile), { recursive: true });
69
+ writeFileSync(indexFile, `${JSON.stringify({ sessions }, null, 2)}
70
+ `, { encoding: "utf8", mode: 384 });
71
+ try {
72
+ chmodSync(indexFile, 384);
73
+ } catch {}
74
+ }
75
+ function registerGitHubApiSession(input) {
76
+ const cleanToken = cleanString2(input.token);
77
+ if (!cleanToken)
78
+ throw new Error("GitHub API session token is required");
79
+ const indexFile = resolveGitHubApiSessionIndexFile(input.projectRoot);
80
+ const createdAt = new Date().toISOString();
81
+ const entry = {
82
+ token: cleanToken,
83
+ login: input.namespace.login,
84
+ userId: input.namespace.userId,
85
+ namespaceKey: input.namespace.key,
86
+ namespaceRoot: input.namespace.root,
87
+ authStateFile: input.namespace.authStateFile,
88
+ checkoutBaseDir: input.namespace.checkoutBaseDir,
89
+ snapshotBaseDir: input.namespace.snapshotBaseDir,
90
+ selectedRepo: cleanString2(input.selectedRepo),
91
+ createdAt
92
+ };
93
+ const previous = readIndex(indexFile).filter((session) => session.token !== cleanToken);
94
+ writeIndex(indexFile, [...previous.slice(-199), entry]);
95
+ return entry;
96
+ }
97
+ function readGitHubApiSession(input) {
98
+ const cleanToken = cleanString2(input.token);
99
+ if (!cleanToken)
100
+ return null;
101
+ return readIndex(resolveGitHubApiSessionIndexFile(input.projectRoot)).find((entry) => entry.token === cleanToken) ?? null;
102
+ }
103
+ export {
104
+ resolveGitHubApiSessionIndexFile,
105
+ registerGitHubApiSession,
106
+ readGitHubApiSession
107
+ };
@@ -1,7 +1,8 @@
1
1
  // @bun
2
2
  // packages/server/src/server-helpers/github-auth-store.ts
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
- import { resolve as resolve2 } from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { dirname as dirname2, resolve as resolve2 } from "path";
5
6
 
6
7
  // packages/server/src/server-helpers/server-paths.ts
7
8
  import { dirname, resolve } from "path";
@@ -39,6 +40,44 @@ function cleanScopes(value) {
39
40
  return clean ? [clean] : [];
40
41
  });
41
42
  }
43
+ function parseApiSessions(value) {
44
+ if (!Array.isArray(value))
45
+ return [];
46
+ return value.flatMap((entry) => {
47
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
48
+ return [];
49
+ const record = entry;
50
+ const token = cleanString(record.token);
51
+ if (!token)
52
+ return [];
53
+ return [{
54
+ token,
55
+ login: cleanString(record.login),
56
+ userId: cleanString(record.userId),
57
+ createdAt: cleanString(record.createdAt) ?? undefined
58
+ }];
59
+ });
60
+ }
61
+ function parsePendingDevice(value) {
62
+ if (!value || typeof value !== "object")
63
+ return null;
64
+ const record = value;
65
+ const pollId = cleanString(record.pollId);
66
+ const deviceCode = cleanString(record.deviceCode);
67
+ const expiresAt = cleanString(record.expiresAt);
68
+ const intervalSeconds = typeof record.intervalSeconds === "number" && Number.isFinite(record.intervalSeconds) ? Math.max(1, Math.floor(record.intervalSeconds)) : null;
69
+ if (!pollId || !deviceCode || !expiresAt || !intervalSeconds)
70
+ return null;
71
+ return { pollId, deviceCode, expiresAt, intervalSeconds };
72
+ }
73
+ function parsePendingDevices(value) {
74
+ if (!Array.isArray(value))
75
+ return [];
76
+ return value.flatMap((entry) => {
77
+ const pending = parsePendingDevice(entry);
78
+ return pending ? [pending] : [];
79
+ });
80
+ }
42
81
  function readStoredAuth(stateFile) {
43
82
  if (!existsSync(stateFile))
44
83
  return {};
@@ -52,37 +91,44 @@ function readStoredAuth(stateFile) {
52
91
  selectedRepo: cleanString(parsed.selectedRepo),
53
92
  tokenSource: parsed.tokenSource === "oauth-device" || parsed.tokenSource === "manual-token" || parsed.tokenSource === "env" ? parsed.tokenSource : undefined,
54
93
  pendingDevice: parsePendingDevice(parsed.pendingDevice),
94
+ pendingDevices: parsePendingDevices(parsed.pendingDevices),
95
+ apiSessions: parseApiSessions(parsed.apiSessions),
55
96
  updatedAt: cleanString(parsed.updatedAt) ?? undefined
56
97
  };
57
98
  } catch {
58
99
  return {};
59
100
  }
60
101
  }
61
- function parsePendingDevice(value) {
62
- if (!value || typeof value !== "object")
63
- return null;
64
- const record = value;
65
- const pollId = cleanString(record.pollId);
66
- const deviceCode = cleanString(record.deviceCode);
67
- const expiresAt = cleanString(record.expiresAt);
68
- const intervalSeconds = typeof record.intervalSeconds === "number" && Number.isFinite(record.intervalSeconds) ? Math.max(1, Math.floor(record.intervalSeconds)) : null;
69
- if (!pollId || !deviceCode || !expiresAt || !intervalSeconds)
70
- return null;
71
- return { pollId, deviceCode, expiresAt, intervalSeconds };
102
+ function newApiSessionToken() {
103
+ return `rig_${randomBytes(32).toString("base64url")}`;
72
104
  }
73
105
  function writeStoredAuth(stateFile, payload) {
74
- mkdirSync(resolve2(stateFile, ".."), { recursive: true });
106
+ mkdirSync(dirname2(stateFile), { recursive: true });
75
107
  writeFileSync(stateFile, `${JSON.stringify(payload, null, 2)}
76
108
  `, { encoding: "utf8", mode: 384 });
77
109
  try {
78
110
  chmodSync(stateFile, 384);
79
111
  } catch {}
80
112
  }
113
+ function localProjectAuthStateFile(projectRoot) {
114
+ return resolve2(projectRoot, ".rig", "state", "github-auth.json");
115
+ }
81
116
  function resolveGitHubAuthStateFile(projectRoot) {
82
117
  return resolve2(resolveServerAuthorityPaths(projectRoot).stateDir, "github-auth.json");
83
118
  }
84
- function createGitHubAuthStore(projectRoot) {
85
- const stateFile = resolveGitHubAuthStateFile(projectRoot);
119
+ function copyGitHubAuthStateToLocalProjectRoot(stateFile, projectRoot) {
120
+ const targetFile = localProjectAuthStateFile(projectRoot);
121
+ mkdirSync(dirname2(targetFile), { recursive: true });
122
+ if (existsSync(stateFile)) {
123
+ copyFileSync(stateFile, targetFile);
124
+ try {
125
+ chmodSync(targetFile, 384);
126
+ } catch {}
127
+ return;
128
+ }
129
+ writeStoredAuth(targetFile, {});
130
+ }
131
+ function createGitHubAuthStoreFromStateFile(stateFile) {
86
132
  return {
87
133
  stateFile,
88
134
  status(options) {
@@ -112,14 +158,53 @@ function createGitHubAuthStore(projectRoot) {
112
158
  scopes: input.scopes ?? [],
113
159
  selectedRepo: input.selectedRepo ?? previous.selectedRepo ?? null,
114
160
  pendingDevice: null,
161
+ pendingDevices: [],
162
+ apiSessions: previous.apiSessions ?? [],
163
+ updatedAt: new Date().toISOString()
164
+ });
165
+ },
166
+ createApiSession() {
167
+ const previous = readStoredAuth(stateFile);
168
+ const token = newApiSessionToken();
169
+ const session = {
170
+ token,
171
+ login: cleanString(previous.login),
172
+ userId: cleanString(previous.userId),
173
+ createdAt: new Date().toISOString()
174
+ };
175
+ writeStoredAuth(stateFile, {
176
+ ...previous,
177
+ apiSessions: [...(previous.apiSessions ?? []).slice(-9), session],
115
178
  updatedAt: new Date().toISOString()
116
179
  });
180
+ return { token, login: session.login ?? null, userId: session.userId ?? null };
181
+ },
182
+ readApiSession(token) {
183
+ const clean = cleanString(token);
184
+ if (!clean)
185
+ return null;
186
+ const previous = readStoredAuth(stateFile);
187
+ const session = (previous.apiSessions ?? []).find((candidate) => candidate.token === clean);
188
+ return session ? { login: cleanString(session.login), userId: cleanString(session.userId) } : null;
189
+ },
190
+ copyToProjectRoot(projectRoot) {
191
+ const targetFile = resolveGitHubAuthStateFile(projectRoot);
192
+ writeStoredAuth(targetFile, readStoredAuth(stateFile));
193
+ },
194
+ copyToLocalProjectRoot(projectRoot) {
195
+ copyGitHubAuthStateToLocalProjectRoot(stateFile, projectRoot);
117
196
  },
118
197
  savePendingDevice(input) {
119
198
  const previous = readStoredAuth(stateFile);
199
+ const pendingDevices = [
200
+ ...previous.pendingDevice ? [previous.pendingDevice] : [],
201
+ ...previous.pendingDevices ?? [],
202
+ input
203
+ ].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.pollId === entry.pollId) === index);
120
204
  writeStoredAuth(stateFile, {
121
205
  ...previous,
122
- pendingDevice: input,
206
+ pendingDevice: null,
207
+ pendingDevices,
123
208
  updatedAt: new Date().toISOString()
124
209
  });
125
210
  },
@@ -132,24 +217,35 @@ function createGitHubAuthStore(projectRoot) {
132
217
  });
133
218
  },
134
219
  readPendingDevice(pollId) {
135
- const pending = readStoredAuth(stateFile).pendingDevice ?? null;
136
- if (!pending || pending.pollId !== pollId)
220
+ const previous = readStoredAuth(stateFile);
221
+ const pending = [
222
+ ...previous.pendingDevice ? [previous.pendingDevice] : [],
223
+ ...previous.pendingDevices ?? []
224
+ ].find((entry) => entry.pollId === pollId) ?? null;
225
+ if (!pending)
137
226
  return null;
138
227
  if (Date.parse(pending.expiresAt) <= Date.now())
139
228
  return null;
140
229
  return pending;
141
230
  },
142
- clearPendingDevice() {
231
+ clearPendingDevice(pollId) {
143
232
  const previous = readStoredAuth(stateFile);
233
+ const remaining = pollId ? (previous.pendingDevices ?? []).filter((entry) => entry.pollId !== pollId) : [];
144
234
  writeStoredAuth(stateFile, {
145
235
  ...previous,
146
236
  pendingDevice: null,
237
+ pendingDevices: remaining,
147
238
  updatedAt: new Date().toISOString()
148
239
  });
149
240
  }
150
241
  };
151
242
  }
243
+ function createGitHubAuthStore(projectRoot) {
244
+ return createGitHubAuthStoreFromStateFile(resolveGitHubAuthStateFile(projectRoot));
245
+ }
152
246
  export {
153
247
  resolveGitHubAuthStateFile,
154
- createGitHubAuthStore
248
+ createGitHubAuthStoreFromStateFile,
249
+ createGitHubAuthStore,
250
+ copyGitHubAuthStateToLocalProjectRoot
155
251
  };
@@ -0,0 +1,102 @@
1
+ // @bun
2
+ // packages/server/src/server-helpers/github-user-namespace.ts
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { dirname, isAbsolute, relative, resolve } from "path";
5
+ function cleanString(value) {
6
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
7
+ }
8
+ function sanitizePathSegment(value) {
9
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 96);
10
+ }
11
+ function deriveGitHubUserNamespaceKey(identity) {
12
+ const userId = cleanString(identity.userId);
13
+ if (userId) {
14
+ const safeId = sanitizePathSegment(userId);
15
+ if (safeId)
16
+ return `ghu-${safeId}`;
17
+ }
18
+ const login = cleanString(identity.login);
19
+ if (login) {
20
+ const safeLogin = sanitizePathSegment(login);
21
+ if (safeLogin)
22
+ return `ghu-login-${safeLogin}`;
23
+ }
24
+ throw new Error("GitHub user namespace requires a user id or login");
25
+ }
26
+ function resolveRemoteUserNamespacesRoot(projectRoot) {
27
+ const explicitRoot = cleanString(process.env.RIG_REMOTE_USER_NAMESPACE_ROOT);
28
+ if (explicitRoot)
29
+ return resolve(explicitRoot);
30
+ const stateDir = cleanString(process.env.RIG_STATE_DIR);
31
+ if (stateDir)
32
+ return resolve(dirname(resolve(stateDir)), "users");
33
+ return resolve(projectRoot, ".rig", "users");
34
+ }
35
+ function resolveRemoteUserNamespace(projectRoot, identity) {
36
+ const key = deriveGitHubUserNamespaceKey(identity);
37
+ const root = resolve(resolveRemoteUserNamespacesRoot(projectRoot), key);
38
+ const stateDir = resolve(root, ".rig", "state");
39
+ return {
40
+ key,
41
+ userId: cleanString(identity.userId),
42
+ login: cleanString(identity.login),
43
+ root,
44
+ stateDir,
45
+ authStateFile: resolve(stateDir, "github-auth.json"),
46
+ metadataFile: resolve(stateDir, "user-namespace.json"),
47
+ checkoutBaseDir: resolve(root, "remote-checkouts"),
48
+ snapshotBaseDir: resolve(root, "remote-snapshots")
49
+ };
50
+ }
51
+ function serializeRemoteUserNamespace(namespace) {
52
+ return {
53
+ key: namespace.key,
54
+ userId: namespace.userId,
55
+ login: namespace.login,
56
+ root: namespace.root,
57
+ checkoutBaseDir: namespace.checkoutBaseDir,
58
+ snapshotBaseDir: namespace.snapshotBaseDir
59
+ };
60
+ }
61
+ function isPathInsideNamespace(namespaceRoot, candidatePath) {
62
+ const root = resolve(namespaceRoot);
63
+ const candidate = resolve(candidatePath);
64
+ const rel = relative(root, candidate);
65
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
66
+ }
67
+ function writeRemoteUserNamespaceMetadata(namespace) {
68
+ mkdirSync(namespace.stateDir, { recursive: true });
69
+ const previous = (() => {
70
+ if (!existsSync(namespace.metadataFile))
71
+ return null;
72
+ try {
73
+ const parsed = JSON.parse(readFileSync(namespace.metadataFile, "utf8"));
74
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ })();
79
+ const now = new Date().toISOString();
80
+ writeFileSync(namespace.metadataFile, `${JSON.stringify({
81
+ key: namespace.key,
82
+ userId: namespace.userId,
83
+ login: namespace.login,
84
+ root: namespace.root,
85
+ checkoutBaseDir: namespace.checkoutBaseDir,
86
+ snapshotBaseDir: namespace.snapshotBaseDir,
87
+ createdAt: typeof previous?.createdAt === "string" ? previous.createdAt : now,
88
+ updatedAt: now
89
+ }, null, 2)}
90
+ `, { encoding: "utf8", mode: 384 });
91
+ try {
92
+ chmodSync(namespace.metadataFile, 384);
93
+ } catch {}
94
+ }
95
+ export {
96
+ writeRemoteUserNamespaceMetadata,
97
+ serializeRemoteUserNamespace,
98
+ resolveRemoteUserNamespacesRoot,
99
+ resolveRemoteUserNamespace,
100
+ isPathInsideNamespace,
101
+ deriveGitHubUserNamespaceKey
102
+ };