@paperclipai/server 0.2.2

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.
Files changed (317) hide show
  1. package/dist/adapters/codex-models.d.ts +4 -0
  2. package/dist/adapters/codex-models.d.ts.map +1 -0
  3. package/dist/adapters/codex-models.js +98 -0
  4. package/dist/adapters/codex-models.js.map +1 -0
  5. package/dist/adapters/http/execute.d.ts +3 -0
  6. package/dist/adapters/http/execute.d.ts.map +1 -0
  7. package/dist/adapters/http/execute.js +39 -0
  8. package/dist/adapters/http/execute.js.map +1 -0
  9. package/dist/adapters/http/index.d.ts +3 -0
  10. package/dist/adapters/http/index.d.ts.map +1 -0
  11. package/dist/adapters/http/index.js +20 -0
  12. package/dist/adapters/http/index.js.map +1 -0
  13. package/dist/adapters/http/test.d.ts +3 -0
  14. package/dist/adapters/http/test.d.ts.map +1 -0
  15. package/dist/adapters/http/test.js +106 -0
  16. package/dist/adapters/http/test.js.map +1 -0
  17. package/dist/adapters/index.d.ts +4 -0
  18. package/dist/adapters/index.d.ts.map +1 -0
  19. package/dist/adapters/index.js +3 -0
  20. package/dist/adapters/index.js.map +1 -0
  21. package/dist/adapters/process/execute.d.ts +3 -0
  22. package/dist/adapters/process/execute.d.ts.map +1 -0
  23. package/dist/adapters/process/execute.js +63 -0
  24. package/dist/adapters/process/execute.js.map +1 -0
  25. package/dist/adapters/process/index.d.ts +3 -0
  26. package/dist/adapters/process/index.d.ts.map +1 -0
  27. package/dist/adapters/process/index.js +23 -0
  28. package/dist/adapters/process/index.js.map +1 -0
  29. package/dist/adapters/process/test.d.ts +3 -0
  30. package/dist/adapters/process/test.d.ts.map +1 -0
  31. package/dist/adapters/process/test.js +77 -0
  32. package/dist/adapters/process/test.js.map +1 -0
  33. package/dist/adapters/registry.d.ts +9 -0
  34. package/dist/adapters/registry.d.ts.map +1 -0
  35. package/dist/adapters/registry.js +63 -0
  36. package/dist/adapters/registry.js.map +1 -0
  37. package/dist/adapters/types.d.ts +2 -0
  38. package/dist/adapters/types.d.ts.map +1 -0
  39. package/dist/adapters/types.js +2 -0
  40. package/dist/adapters/types.js.map +1 -0
  41. package/dist/adapters/utils.d.ts +10 -0
  42. package/dist/adapters/utils.d.ts.map +1 -0
  43. package/dist/adapters/utils.js +14 -0
  44. package/dist/adapters/utils.js.map +1 -0
  45. package/dist/agent-auth-jwt.d.ts +14 -0
  46. package/dist/agent-auth-jwt.d.ts.map +1 -0
  47. package/dist/agent-auth-jwt.js +117 -0
  48. package/dist/agent-auth-jwt.js.map +1 -0
  49. package/dist/app.d.ts +20 -0
  50. package/dist/app.d.ts.map +1 -0
  51. package/dist/app.js +127 -0
  52. package/dist/app.js.map +1 -0
  53. package/dist/auth/better-auth.d.ts +23 -0
  54. package/dist/auth/better-auth.d.ts.map +1 -0
  55. package/dist/auth/better-auth.js +80 -0
  56. package/dist/auth/better-auth.js.map +1 -0
  57. package/dist/board-claim.d.ts +23 -0
  58. package/dist/board-claim.d.ts.map +1 -0
  59. package/dist/board-claim.js +115 -0
  60. package/dist/board-claim.js.map +1 -0
  61. package/dist/config-file.d.ts +3 -0
  62. package/dist/config-file.d.ts.map +1 -0
  63. package/dist/config-file.js +16 -0
  64. package/dist/config-file.js.map +1 -0
  65. package/dist/config.d.ts +33 -0
  66. package/dist/config.d.ts.map +1 -0
  67. package/dist/config.js +114 -0
  68. package/dist/config.js.map +1 -0
  69. package/dist/errors.d.ts +12 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +28 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/home-paths.d.ts +11 -0
  74. package/dist/home-paths.d.ts.map +1 -0
  75. package/dist/home-paths.js +54 -0
  76. package/dist/home-paths.js.map +1 -0
  77. package/dist/index.d.ts +2 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +439 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/middleware/auth.d.ts +12 -0
  82. package/dist/middleware/auth.d.ts.map +1 -0
  83. package/dist/middleware/auth.js +124 -0
  84. package/dist/middleware/auth.js.map +1 -0
  85. package/dist/middleware/board-mutation-guard.d.ts +3 -0
  86. package/dist/middleware/board-mutation-guard.d.ts.map +1 -0
  87. package/dist/middleware/board-mutation-guard.js +60 -0
  88. package/dist/middleware/board-mutation-guard.js.map +1 -0
  89. package/dist/middleware/error-handler.d.ts +3 -0
  90. package/dist/middleware/error-handler.d.ts.map +1 -0
  91. package/dist/middleware/error-handler.js +22 -0
  92. package/dist/middleware/error-handler.js.map +1 -0
  93. package/dist/middleware/index.d.ts +4 -0
  94. package/dist/middleware/index.d.ts.map +1 -0
  95. package/dist/middleware/index.js +4 -0
  96. package/dist/middleware/index.js.map +1 -0
  97. package/dist/middleware/logger.d.ts +4 -0
  98. package/dist/middleware/logger.d.ts.map +1 -0
  99. package/dist/middleware/logger.js +37 -0
  100. package/dist/middleware/logger.js.map +1 -0
  101. package/dist/middleware/private-hostname-guard.d.ts +11 -0
  102. package/dist/middleware/private-hostname-guard.d.ts.map +1 -0
  103. package/dist/middleware/private-hostname-guard.js +78 -0
  104. package/dist/middleware/private-hostname-guard.js.map +1 -0
  105. package/dist/middleware/validate.d.ts +4 -0
  106. package/dist/middleware/validate.d.ts.map +1 -0
  107. package/dist/middleware/validate.js +7 -0
  108. package/dist/middleware/validate.js.map +1 -0
  109. package/dist/paths.d.ts +3 -0
  110. package/dist/paths.d.ts.map +1 -0
  111. package/dist/paths.js +31 -0
  112. package/dist/paths.js.map +1 -0
  113. package/dist/realtime/live-events-ws.d.ts +10 -0
  114. package/dist/realtime/live-events-ws.d.ts.map +1 -0
  115. package/dist/realtime/live-events-ws.js +185 -0
  116. package/dist/realtime/live-events-ws.js.map +1 -0
  117. package/dist/redaction.d.ts +4 -0
  118. package/dist/redaction.d.ts.map +1 -0
  119. package/dist/redaction.js +63 -0
  120. package/dist/redaction.js.map +1 -0
  121. package/dist/routes/access.d.ts +9 -0
  122. package/dist/routes/access.d.ts.map +1 -0
  123. package/dist/routes/access.js +887 -0
  124. package/dist/routes/access.js.map +1 -0
  125. package/dist/routes/activity.d.ts +3 -0
  126. package/dist/routes/activity.d.ts.map +1 -0
  127. package/dist/routes/activity.js +87 -0
  128. package/dist/routes/activity.js.map +1 -0
  129. package/dist/routes/agents.d.ts +3 -0
  130. package/dist/routes/agents.d.ts.map +1 -0
  131. package/dist/routes/agents.js +1132 -0
  132. package/dist/routes/agents.js.map +1 -0
  133. package/dist/routes/approvals.d.ts +3 -0
  134. package/dist/routes/approvals.d.ts.map +1 -0
  135. package/dist/routes/approvals.js +271 -0
  136. package/dist/routes/approvals.js.map +1 -0
  137. package/dist/routes/assets.d.ts +4 -0
  138. package/dist/routes/assets.d.ts.map +1 -0
  139. package/dist/routes/assets.js +138 -0
  140. package/dist/routes/assets.js.map +1 -0
  141. package/dist/routes/authz.d.ts +15 -0
  142. package/dist/routes/authz.d.ts.map +1 -0
  143. package/dist/routes/authz.js +40 -0
  144. package/dist/routes/authz.js.map +1 -0
  145. package/dist/routes/companies.d.ts +3 -0
  146. package/dist/routes/companies.d.ts.map +1 -0
  147. package/dist/routes/companies.js +159 -0
  148. package/dist/routes/companies.js.map +1 -0
  149. package/dist/routes/costs.d.ts +3 -0
  150. package/dist/routes/costs.d.ts.map +1 -0
  151. package/dist/routes/costs.js +113 -0
  152. package/dist/routes/costs.js.map +1 -0
  153. package/dist/routes/dashboard.d.ts +3 -0
  154. package/dist/routes/dashboard.d.ts.map +1 -0
  155. package/dist/routes/dashboard.js +15 -0
  156. package/dist/routes/dashboard.js.map +1 -0
  157. package/dist/routes/goals.d.ts +3 -0
  158. package/dist/routes/goals.d.ts.map +1 -0
  159. package/dist/routes/goals.js +95 -0
  160. package/dist/routes/goals.js.map +1 -0
  161. package/dist/routes/health.d.ts +9 -0
  162. package/dist/routes/health.d.ts.map +1 -0
  163. package/dist/routes/health.js +38 -0
  164. package/dist/routes/health.js.map +1 -0
  165. package/dist/routes/index.d.ts +15 -0
  166. package/dist/routes/index.d.ts.map +1 -0
  167. package/dist/routes/index.js +15 -0
  168. package/dist/routes/index.js.map +1 -0
  169. package/dist/routes/issues.d.ts +4 -0
  170. package/dist/routes/issues.d.ts.map +1 -0
  171. package/dist/routes/issues.js +973 -0
  172. package/dist/routes/issues.js.map +1 -0
  173. package/dist/routes/llms.d.ts +3 -0
  174. package/dist/routes/llms.d.ts.map +1 -0
  175. package/dist/routes/llms.js +78 -0
  176. package/dist/routes/llms.js.map +1 -0
  177. package/dist/routes/projects.d.ts +3 -0
  178. package/dist/routes/projects.d.ts.map +1 -0
  179. package/dist/routes/projects.js +253 -0
  180. package/dist/routes/projects.js.map +1 -0
  181. package/dist/routes/secrets.d.ts +3 -0
  182. package/dist/routes/secrets.d.ts.map +1 -0
  183. package/dist/routes/secrets.js +128 -0
  184. package/dist/routes/secrets.js.map +1 -0
  185. package/dist/routes/sidebar-badges.d.ts +3 -0
  186. package/dist/routes/sidebar-badges.d.ts.map +1 -0
  187. package/dist/routes/sidebar-badges.js +47 -0
  188. package/dist/routes/sidebar-badges.js.map +1 -0
  189. package/dist/secrets/external-stub-providers.d.ts +5 -0
  190. package/dist/secrets/external-stub-providers.d.ts.map +1 -0
  191. package/dist/secrets/external-stub-providers.js +21 -0
  192. package/dist/secrets/external-stub-providers.js.map +1 -0
  193. package/dist/secrets/local-encrypted-provider.d.ts +3 -0
  194. package/dist/secrets/local-encrypted-provider.d.ts.map +1 -0
  195. package/dist/secrets/local-encrypted-provider.js +116 -0
  196. package/dist/secrets/local-encrypted-provider.js.map +1 -0
  197. package/dist/secrets/provider-registry.d.ts +5 -0
  198. package/dist/secrets/provider-registry.d.ts.map +1 -0
  199. package/dist/secrets/provider-registry.js +20 -0
  200. package/dist/secrets/provider-registry.js.map +1 -0
  201. package/dist/secrets/types.d.ts +21 -0
  202. package/dist/secrets/types.d.ts.map +1 -0
  203. package/dist/secrets/types.js +2 -0
  204. package/dist/secrets/types.js.map +1 -0
  205. package/dist/services/access.d.ts +81 -0
  206. package/dist/services/access.d.ts.map +1 -0
  207. package/dist/services/access.js +187 -0
  208. package/dist/services/access.js.map +1 -0
  209. package/dist/services/activity-log.d.ts +14 -0
  210. package/dist/services/activity-log.d.ts.map +1 -0
  211. package/dist/services/activity-log.js +32 -0
  212. package/dist/services/activity-log.js.map +1 -0
  213. package/dist/services/activity.d.ts +764 -0
  214. package/dist/services/activity.d.ts.map +1 -0
  215. package/dist/services/activity.js +105 -0
  216. package/dist/services/activity.js.map +1 -0
  217. package/dist/services/agent-permissions.d.ts +6 -0
  218. package/dist/services/agent-permissions.d.ts.map +1 -0
  219. package/dist/services/agent-permissions.js +18 -0
  220. package/dist/services/agent-permissions.js.map +1 -0
  221. package/dist/services/agents.d.ts +1494 -0
  222. package/dist/services/agents.d.ts.map +1 -0
  223. package/dist/services/agents.js +454 -0
  224. package/dist/services/agents.js.map +1 -0
  225. package/dist/services/approvals.d.ts +540 -0
  226. package/dist/services/approvals.d.ts.map +1 -0
  227. package/dist/services/approvals.js +173 -0
  228. package/dist/services/approvals.js.map +1 -0
  229. package/dist/services/assets.d.ts +33 -0
  230. package/dist/services/assets.d.ts.map +1 -0
  231. package/dist/services/assets.js +17 -0
  232. package/dist/services/assets.js.map +1 -0
  233. package/dist/services/companies.d.ts +503 -0
  234. package/dist/services/companies.d.ts.map +1 -0
  235. package/dist/services/companies.js +120 -0
  236. package/dist/services/companies.js.map +1 -0
  237. package/dist/services/company-portability.d.ts +8 -0
  238. package/dist/services/company-portability.d.ts.map +1 -0
  239. package/dist/services/company-portability.js +851 -0
  240. package/dist/services/company-portability.js.map +1 -0
  241. package/dist/services/costs.d.ts +50 -0
  242. package/dist/services/costs.d.ts.map +1 -0
  243. package/dist/services/costs.js +166 -0
  244. package/dist/services/costs.js.map +1 -0
  245. package/dist/services/dashboard.d.ts +21 -0
  246. package/dist/services/dashboard.d.ts.map +1 -0
  247. package/dist/services/dashboard.js +96 -0
  248. package/dist/services/dashboard.js.map +1 -0
  249. package/dist/services/goals.d.ts +407 -0
  250. package/dist/services/goals.d.ts.map +1 -0
  251. package/dist/services/goals.js +29 -0
  252. package/dist/services/goals.js.map +1 -0
  253. package/dist/services/heartbeat.d.ts +1666 -0
  254. package/dist/services/heartbeat.d.ts.map +1 -0
  255. package/dist/services/heartbeat.js +1752 -0
  256. package/dist/services/heartbeat.js.map +1 -0
  257. package/dist/services/index.d.ts +20 -0
  258. package/dist/services/index.d.ts.map +1 -0
  259. package/dist/services/index.js +20 -0
  260. package/dist/services/index.js.map +1 -0
  261. package/dist/services/issue-approvals.d.ts +56 -0
  262. package/dist/services/issue-approvals.d.ts.map +1 -0
  263. package/dist/services/issue-approvals.js +153 -0
  264. package/dist/services/issue-approvals.js.map +1 -0
  265. package/dist/services/issues.d.ts +756 -0
  266. package/dist/services/issues.d.ts.map +1 -0
  267. package/dist/services/issues.js +917 -0
  268. package/dist/services/issues.js.map +1 -0
  269. package/dist/services/live-events.d.ts +12 -0
  270. package/dist/services/live-events.d.ts.map +1 -0
  271. package/dist/services/live-events.js +24 -0
  272. package/dist/services/live-events.js.map +1 -0
  273. package/dist/services/projects.d.ts +66 -0
  274. package/dist/services/projects.d.ts.map +1 -0
  275. package/dist/services/projects.js +472 -0
  276. package/dist/services/projects.js.map +1 -0
  277. package/dist/services/run-log-store.d.ts +34 -0
  278. package/dist/services/run-log-store.d.ts.map +1 -0
  279. package/dist/services/run-log-store.js +112 -0
  280. package/dist/services/run-log-store.js.map +1 -0
  281. package/dist/services/secrets.d.ts +506 -0
  282. package/dist/services/secrets.d.ts.map +1 -0
  283. package/dist/services/secrets.js +284 -0
  284. package/dist/services/secrets.js.map +1 -0
  285. package/dist/services/sidebar-badges.d.ts +9 -0
  286. package/dist/services/sidebar-badges.d.ts.map +1 -0
  287. package/dist/services/sidebar-badges.js +33 -0
  288. package/dist/services/sidebar-badges.js.map +1 -0
  289. package/dist/startup-banner.d.ts +27 -0
  290. package/dist/startup-banner.d.ts.map +1 -0
  291. package/dist/startup-banner.js +112 -0
  292. package/dist/startup-banner.js.map +1 -0
  293. package/dist/storage/index.d.ts +6 -0
  294. package/dist/storage/index.d.ts.map +1 -0
  295. package/dist/storage/index.js +29 -0
  296. package/dist/storage/index.js.map +1 -0
  297. package/dist/storage/local-disk-provider.d.ts +3 -0
  298. package/dist/storage/local-disk-provider.d.ts.map +1 -0
  299. package/dist/storage/local-disk-provider.js +79 -0
  300. package/dist/storage/local-disk-provider.js.map +1 -0
  301. package/dist/storage/provider-registry.d.ts +4 -0
  302. package/dist/storage/provider-registry.d.ts.map +1 -0
  303. package/dist/storage/provider-registry.js +15 -0
  304. package/dist/storage/provider-registry.js.map +1 -0
  305. package/dist/storage/s3-provider.d.ts +11 -0
  306. package/dist/storage/s3-provider.d.ts.map +1 -0
  307. package/dist/storage/s3-provider.js +123 -0
  308. package/dist/storage/s3-provider.js.map +1 -0
  309. package/dist/storage/service.d.ts +3 -0
  310. package/dist/storage/service.d.ts.map +1 -0
  311. package/dist/storage/service.js +120 -0
  312. package/dist/storage/service.js.map +1 -0
  313. package/dist/storage/types.d.ts +55 -0
  314. package/dist/storage/types.d.ts.map +1 -0
  315. package/dist/storage/types.js +2 -0
  316. package/dist/storage/types.js.map +1 -0
  317. package/package.json +62 -0
@@ -0,0 +1,887 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Router } from "express";
6
+ import { and, eq, isNull, desc } from "drizzle-orm";
7
+ import { agentApiKeys, authUsers, invites, joinRequests, } from "@paperclipai/db";
8
+ import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS, } from "@paperclipai/shared";
9
+ import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
10
+ import { validate } from "../middleware/validate.js";
11
+ import { accessService, agentService, logActivity } from "../services/index.js";
12
+ import { assertCompanyAccess } from "./authz.js";
13
+ import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js";
14
+ function hashToken(token) {
15
+ return createHash("sha256").update(token).digest("hex");
16
+ }
17
+ function createInviteToken() {
18
+ return `pcp_invite_${randomBytes(24).toString("hex")}`;
19
+ }
20
+ function createClaimSecret() {
21
+ return `pcp_claim_${randomBytes(24).toString("hex")}`;
22
+ }
23
+ function tokenHashesMatch(left, right) {
24
+ const leftBytes = Buffer.from(left, "utf8");
25
+ const rightBytes = Buffer.from(right, "utf8");
26
+ return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
27
+ }
28
+ function requestBaseUrl(req) {
29
+ const forwardedProto = req.header("x-forwarded-proto");
30
+ const proto = forwardedProto?.split(",")[0]?.trim() || req.protocol || "http";
31
+ const host = req.header("x-forwarded-host")?.split(",")[0]?.trim() || req.header("host");
32
+ if (!host)
33
+ return "";
34
+ return `${proto}://${host}`;
35
+ }
36
+ function readSkillMarkdown(skillName) {
37
+ const normalized = skillName.trim().toLowerCase();
38
+ if (normalized !== "paperclip" && normalized !== "paperclip-create-agent")
39
+ return null;
40
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
41
+ const candidates = [
42
+ path.resolve(process.cwd(), "skills", normalized, "SKILL.md"),
43
+ path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md"),
44
+ ];
45
+ for (const skillPath of candidates) {
46
+ try {
47
+ return fs.readFileSync(skillPath, "utf8");
48
+ }
49
+ catch {
50
+ // Continue to next candidate.
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ function toJoinRequestResponse(row) {
56
+ const { claimSecretHash: _claimSecretHash, ...safe } = row;
57
+ return safe;
58
+ }
59
+ function isPlainObject(value) {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value);
61
+ }
62
+ function isLoopbackHost(hostname) {
63
+ const value = hostname.trim().toLowerCase();
64
+ return value === "localhost" || value === "127.0.0.1" || value === "::1";
65
+ }
66
+ function normalizeHostname(value) {
67
+ if (!value)
68
+ return null;
69
+ const trimmed = value.trim();
70
+ if (!trimmed)
71
+ return null;
72
+ if (trimmed.startsWith("[")) {
73
+ const end = trimmed.indexOf("]");
74
+ return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
75
+ }
76
+ const firstColon = trimmed.indexOf(":");
77
+ if (firstColon > -1)
78
+ return trimmed.slice(0, firstColon).toLowerCase();
79
+ return trimmed.toLowerCase();
80
+ }
81
+ function normalizeHeaderMap(input) {
82
+ if (!isPlainObject(input))
83
+ return undefined;
84
+ const out = {};
85
+ for (const [key, value] of Object.entries(input)) {
86
+ if (typeof value !== "string")
87
+ continue;
88
+ const trimmedKey = key.trim();
89
+ const trimmedValue = value.trim();
90
+ if (!trimmedKey || !trimmedValue)
91
+ continue;
92
+ out[trimmedKey] = trimmedValue;
93
+ }
94
+ return Object.keys(out).length > 0 ? out : undefined;
95
+ }
96
+ function buildJoinConnectivityDiagnostics(input) {
97
+ const diagnostics = [];
98
+ const bindHost = normalizeHostname(input.bindHost);
99
+ const callbackHost = input.callbackUrl ? normalizeHostname(input.callbackUrl.hostname) : null;
100
+ const allowSet = new Set(input.allowedHostnames
101
+ .map((entry) => normalizeHostname(entry))
102
+ .filter((entry) => Boolean(entry)));
103
+ diagnostics.push({
104
+ code: "openclaw_deployment_context",
105
+ level: "info",
106
+ message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`,
107
+ });
108
+ if (input.deploymentMode === "authenticated" && input.deploymentExposure === "private") {
109
+ if (!bindHost || isLoopbackHost(bindHost)) {
110
+ diagnostics.push({
111
+ code: "openclaw_private_bind_loopback",
112
+ level: "warn",
113
+ message: "Paperclip is bound to loopback in authenticated/private mode.",
114
+ hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks.",
115
+ });
116
+ }
117
+ if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
118
+ diagnostics.push({
119
+ code: "openclaw_private_bind_not_allowed",
120
+ level: "warn",
121
+ message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`,
122
+ hint: `Run pnpm paperclipai allowed-hostname ${bindHost}`,
123
+ });
124
+ }
125
+ if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) {
126
+ diagnostics.push({
127
+ code: "openclaw_private_allowed_hostnames_empty",
128
+ level: "warn",
129
+ message: "No explicit allowed hostnames are configured for authenticated/private mode.",
130
+ hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs off-host.",
131
+ });
132
+ }
133
+ }
134
+ if (input.deploymentMode === "authenticated" &&
135
+ input.deploymentExposure === "public" &&
136
+ input.callbackUrl &&
137
+ input.callbackUrl.protocol !== "https:") {
138
+ diagnostics.push({
139
+ code: "openclaw_public_http_callback",
140
+ level: "warn",
141
+ message: "OpenClaw callback URL uses HTTP in authenticated/public mode.",
142
+ hint: "Prefer HTTPS for public deployments.",
143
+ });
144
+ }
145
+ return diagnostics;
146
+ }
147
+ function normalizeAgentDefaultsForJoin(input) {
148
+ const diagnostics = [];
149
+ if (input.adapterType !== "openclaw") {
150
+ const normalized = isPlainObject(input.defaultsPayload)
151
+ ? input.defaultsPayload
152
+ : null;
153
+ return { normalized, diagnostics };
154
+ }
155
+ if (!isPlainObject(input.defaultsPayload)) {
156
+ diagnostics.push({
157
+ code: "openclaw_callback_config_missing",
158
+ level: "warn",
159
+ message: "No OpenClaw callback config was provided in agentDefaultsPayload.",
160
+ hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw webhook immediately after approval.",
161
+ });
162
+ return { normalized: null, diagnostics };
163
+ }
164
+ const defaults = input.defaultsPayload;
165
+ const normalized = {};
166
+ let callbackUrl = null;
167
+ const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : "";
168
+ if (!rawUrl) {
169
+ diagnostics.push({
170
+ code: "openclaw_callback_url_missing",
171
+ level: "warn",
172
+ message: "OpenClaw callback URL is missing.",
173
+ hint: "Set agentDefaultsPayload.url to your OpenClaw webhook endpoint.",
174
+ });
175
+ }
176
+ else {
177
+ try {
178
+ callbackUrl = new URL(rawUrl);
179
+ if (callbackUrl.protocol !== "http:" && callbackUrl.protocol !== "https:") {
180
+ diagnostics.push({
181
+ code: "openclaw_callback_url_protocol",
182
+ level: "warn",
183
+ message: `Unsupported callback protocol: ${callbackUrl.protocol}`,
184
+ hint: "Use http:// or https://.",
185
+ });
186
+ }
187
+ else {
188
+ normalized.url = callbackUrl.toString();
189
+ diagnostics.push({
190
+ code: "openclaw_callback_url_configured",
191
+ level: "info",
192
+ message: `Callback endpoint set to ${callbackUrl.toString()}`,
193
+ });
194
+ }
195
+ if (isLoopbackHost(callbackUrl.hostname)) {
196
+ diagnostics.push({
197
+ code: "openclaw_callback_loopback",
198
+ level: "warn",
199
+ message: "OpenClaw callback endpoint uses loopback hostname.",
200
+ hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.",
201
+ });
202
+ }
203
+ }
204
+ catch {
205
+ diagnostics.push({
206
+ code: "openclaw_callback_url_invalid",
207
+ level: "warn",
208
+ message: `Invalid callback URL: ${rawUrl}`,
209
+ });
210
+ }
211
+ }
212
+ const rawMethod = typeof defaults.method === "string" ? defaults.method.trim().toUpperCase() : "";
213
+ normalized.method = rawMethod || "POST";
214
+ if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) {
215
+ normalized.timeoutSec = Math.max(1, Math.min(120, Math.floor(defaults.timeoutSec)));
216
+ }
217
+ const headers = normalizeHeaderMap(defaults.headers);
218
+ if (headers)
219
+ normalized.headers = headers;
220
+ if (typeof defaults.webhookAuthHeader === "string" && defaults.webhookAuthHeader.trim()) {
221
+ normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim();
222
+ }
223
+ if (isPlainObject(defaults.payloadTemplate)) {
224
+ normalized.payloadTemplate = defaults.payloadTemplate;
225
+ }
226
+ diagnostics.push(...buildJoinConnectivityDiagnostics({
227
+ deploymentMode: input.deploymentMode,
228
+ deploymentExposure: input.deploymentExposure,
229
+ bindHost: input.bindHost,
230
+ allowedHostnames: input.allowedHostnames,
231
+ callbackUrl,
232
+ }));
233
+ return { normalized, diagnostics };
234
+ }
235
+ function toInviteSummaryResponse(req, token, invite) {
236
+ const baseUrl = requestBaseUrl(req);
237
+ const onboardingPath = `/api/invites/${token}/onboarding`;
238
+ return {
239
+ id: invite.id,
240
+ companyId: invite.companyId,
241
+ inviteType: invite.inviteType,
242
+ allowedJoinTypes: invite.allowedJoinTypes,
243
+ expiresAt: invite.expiresAt,
244
+ onboardingPath,
245
+ onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
246
+ skillIndexPath: "/api/skills/index",
247
+ skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index",
248
+ };
249
+ }
250
+ function buildInviteOnboardingManifest(req, token, invite, opts) {
251
+ const baseUrl = requestBaseUrl(req);
252
+ const skillPath = "/api/skills/paperclip";
253
+ const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
254
+ const registrationEndpointPath = `/api/invites/${token}/accept`;
255
+ const registrationEndpointUrl = baseUrl ? `${baseUrl}${registrationEndpointPath}` : registrationEndpointPath;
256
+ return {
257
+ invite: toInviteSummaryResponse(req, token, invite),
258
+ onboarding: {
259
+ instructions: "Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.",
260
+ recommendedAdapterType: "openclaw",
261
+ requiredFields: {
262
+ requestType: "agent",
263
+ agentName: "Display name for this agent",
264
+ adapterType: "Use 'openclaw' for OpenClaw webhook-based agents",
265
+ capabilities: "Optional capability summary",
266
+ agentDefaultsPayload: "Optional adapter config such as url/method/headers/webhookAuthHeader for OpenClaw callback endpoint",
267
+ },
268
+ registrationEndpoint: {
269
+ method: "POST",
270
+ path: registrationEndpointPath,
271
+ url: registrationEndpointUrl,
272
+ },
273
+ claimEndpointTemplate: {
274
+ method: "POST",
275
+ path: "/api/join-requests/{requestId}/claim-api-key",
276
+ body: {
277
+ claimSecret: "one-time claim secret returned when the join request is created",
278
+ },
279
+ },
280
+ connectivity: {
281
+ deploymentMode: opts.deploymentMode,
282
+ deploymentExposure: opts.deploymentExposure,
283
+ bindHost: opts.bindHost,
284
+ allowedHostnames: opts.allowedHostnames,
285
+ guidance: opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"
286
+ ? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
287
+ : "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.",
288
+ },
289
+ skill: {
290
+ name: "paperclip",
291
+ path: skillPath,
292
+ url: skillUrl,
293
+ installPath: "~/.openclaw/skills/paperclip/SKILL.md",
294
+ },
295
+ },
296
+ };
297
+ }
298
+ function requestIp(req) {
299
+ const forwarded = req.header("x-forwarded-for");
300
+ if (forwarded) {
301
+ const first = forwarded.split(",")[0]?.trim();
302
+ if (first)
303
+ return first;
304
+ }
305
+ return req.ip || "unknown";
306
+ }
307
+ function inviteExpired(invite) {
308
+ return invite.expiresAt.getTime() <= Date.now();
309
+ }
310
+ function isLocalImplicit(req) {
311
+ return req.actor.type === "board" && req.actor.source === "local_implicit";
312
+ }
313
+ async function resolveActorEmail(db, req) {
314
+ if (isLocalImplicit(req))
315
+ return "local@paperclip.local";
316
+ const userId = req.actor.userId;
317
+ if (!userId)
318
+ return null;
319
+ const user = await db
320
+ .select({ email: authUsers.email })
321
+ .from(authUsers)
322
+ .where(eq(authUsers.id, userId))
323
+ .then((rows) => rows[0] ?? null);
324
+ return user?.email ?? null;
325
+ }
326
+ function grantsFromDefaults(defaultsPayload, key) {
327
+ if (!defaultsPayload || typeof defaultsPayload !== "object")
328
+ return [];
329
+ const scoped = defaultsPayload[key];
330
+ if (!scoped || typeof scoped !== "object")
331
+ return [];
332
+ const grants = scoped.grants;
333
+ if (!Array.isArray(grants))
334
+ return [];
335
+ const validPermissionKeys = new Set(PERMISSION_KEYS);
336
+ const result = [];
337
+ for (const item of grants) {
338
+ if (!item || typeof item !== "object")
339
+ continue;
340
+ const record = item;
341
+ if (typeof record.permissionKey !== "string")
342
+ continue;
343
+ if (!validPermissionKeys.has(record.permissionKey))
344
+ continue;
345
+ result.push({
346
+ permissionKey: record.permissionKey,
347
+ scope: record.scope && typeof record.scope === "object" && !Array.isArray(record.scope)
348
+ ? record.scope
349
+ : null,
350
+ });
351
+ }
352
+ return result;
353
+ }
354
+ export function accessRoutes(db, opts) {
355
+ const router = Router();
356
+ const access = accessService(db);
357
+ const agents = agentService(db);
358
+ async function assertInstanceAdmin(req) {
359
+ if (req.actor.type !== "board")
360
+ throw unauthorized();
361
+ if (isLocalImplicit(req))
362
+ return;
363
+ const allowed = await access.isInstanceAdmin(req.actor.userId);
364
+ if (!allowed)
365
+ throw forbidden("Instance admin required");
366
+ }
367
+ router.get("/board-claim/:token", async (req, res) => {
368
+ const token = req.params.token.trim();
369
+ const code = typeof req.query.code === "string" ? req.query.code.trim() : undefined;
370
+ if (!token)
371
+ throw notFound("Board claim challenge not found");
372
+ const challenge = inspectBoardClaimChallenge(token, code);
373
+ if (challenge.status === "invalid")
374
+ throw notFound("Board claim challenge not found");
375
+ res.json(challenge);
376
+ });
377
+ router.post("/board-claim/:token/claim", async (req, res) => {
378
+ const token = req.params.token.trim();
379
+ const code = typeof req.body?.code === "string" ? req.body.code.trim() : undefined;
380
+ if (!token)
381
+ throw notFound("Board claim challenge not found");
382
+ if (!code)
383
+ throw badRequest("Claim code is required");
384
+ if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId) {
385
+ throw unauthorized("Sign in before claiming board ownership");
386
+ }
387
+ const claimed = await claimBoardOwnership(db, {
388
+ token,
389
+ code,
390
+ userId: req.actor.userId,
391
+ });
392
+ if (claimed.status === "invalid")
393
+ throw notFound("Board claim challenge not found");
394
+ if (claimed.status === "expired")
395
+ throw conflict("Board claim challenge expired. Restart server to generate a new one.");
396
+ if (claimed.status === "claimed") {
397
+ res.json({ claimed: true, userId: claimed.claimedByUserId ?? req.actor.userId });
398
+ return;
399
+ }
400
+ throw conflict("Board claim challenge is no longer available");
401
+ });
402
+ async function assertCompanyPermission(req, companyId, permissionKey) {
403
+ assertCompanyAccess(req, companyId);
404
+ if (req.actor.type === "agent") {
405
+ if (!req.actor.agentId)
406
+ throw forbidden();
407
+ const allowed = await access.hasPermission(companyId, "agent", req.actor.agentId, permissionKey);
408
+ if (!allowed)
409
+ throw forbidden("Permission denied");
410
+ return;
411
+ }
412
+ if (req.actor.type !== "board")
413
+ throw unauthorized();
414
+ if (isLocalImplicit(req))
415
+ return;
416
+ const allowed = await access.canUser(companyId, req.actor.userId, permissionKey);
417
+ if (!allowed)
418
+ throw forbidden("Permission denied");
419
+ }
420
+ router.get("/skills/index", (_req, res) => {
421
+ res.json({
422
+ skills: [
423
+ { name: "paperclip", path: "/api/skills/paperclip" },
424
+ { name: "paperclip-create-agent", path: "/api/skills/paperclip-create-agent" },
425
+ ],
426
+ });
427
+ });
428
+ router.get("/skills/:skillName", (req, res) => {
429
+ const skillName = req.params.skillName.trim().toLowerCase();
430
+ const markdown = readSkillMarkdown(skillName);
431
+ if (!markdown)
432
+ throw notFound("Skill not found");
433
+ res.type("text/markdown").send(markdown);
434
+ });
435
+ router.post("/companies/:companyId/invites", validate(createCompanyInviteSchema), async (req, res) => {
436
+ const companyId = req.params.companyId;
437
+ await assertCompanyPermission(req, companyId, "users:invite");
438
+ const token = createInviteToken();
439
+ const created = await db
440
+ .insert(invites)
441
+ .values({
442
+ companyId,
443
+ inviteType: "company_join",
444
+ tokenHash: hashToken(token),
445
+ allowedJoinTypes: req.body.allowedJoinTypes,
446
+ defaultsPayload: req.body.defaultsPayload ?? null,
447
+ expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000),
448
+ invitedByUserId: req.actor.userId ?? null,
449
+ })
450
+ .returning()
451
+ .then((rows) => rows[0]);
452
+ await logActivity(db, {
453
+ companyId,
454
+ actorType: req.actor.type === "agent" ? "agent" : "user",
455
+ actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
456
+ action: "invite.created",
457
+ entityType: "invite",
458
+ entityId: created.id,
459
+ details: {
460
+ inviteType: created.inviteType,
461
+ allowedJoinTypes: created.allowedJoinTypes,
462
+ expiresAt: created.expiresAt.toISOString(),
463
+ },
464
+ });
465
+ res.status(201).json({
466
+ ...created,
467
+ token,
468
+ inviteUrl: `/invite/${token}`,
469
+ });
470
+ });
471
+ router.get("/invites/:token", async (req, res) => {
472
+ const token = req.params.token.trim();
473
+ if (!token)
474
+ throw notFound("Invite not found");
475
+ const invite = await db
476
+ .select()
477
+ .from(invites)
478
+ .where(eq(invites.tokenHash, hashToken(token)))
479
+ .then((rows) => rows[0] ?? null);
480
+ if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
481
+ throw notFound("Invite not found");
482
+ }
483
+ res.json(toInviteSummaryResponse(req, token, invite));
484
+ });
485
+ router.get("/invites/:token/onboarding", async (req, res) => {
486
+ const token = req.params.token.trim();
487
+ if (!token)
488
+ throw notFound("Invite not found");
489
+ const invite = await db
490
+ .select()
491
+ .from(invites)
492
+ .where(eq(invites.tokenHash, hashToken(token)))
493
+ .then((rows) => rows[0] ?? null);
494
+ if (!invite || invite.revokedAt || inviteExpired(invite)) {
495
+ throw notFound("Invite not found");
496
+ }
497
+ res.json(buildInviteOnboardingManifest(req, token, invite, opts));
498
+ });
499
+ router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
500
+ const token = req.params.token.trim();
501
+ if (!token)
502
+ throw notFound("Invite not found");
503
+ const invite = await db
504
+ .select()
505
+ .from(invites)
506
+ .where(eq(invites.tokenHash, hashToken(token)))
507
+ .then((rows) => rows[0] ?? null);
508
+ if (!invite || invite.revokedAt || invite.acceptedAt || inviteExpired(invite)) {
509
+ throw notFound("Invite not found");
510
+ }
511
+ if (invite.inviteType === "bootstrap_ceo") {
512
+ if (req.body.requestType !== "human") {
513
+ throw badRequest("Bootstrap invite requires human request type");
514
+ }
515
+ if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) {
516
+ throw unauthorized("Authenticated user required for bootstrap acceptance");
517
+ }
518
+ const userId = req.actor.userId ?? "local-board";
519
+ const existingAdmin = await access.isInstanceAdmin(userId);
520
+ if (!existingAdmin) {
521
+ await access.promoteInstanceAdmin(userId);
522
+ }
523
+ const updatedInvite = await db
524
+ .update(invites)
525
+ .set({ acceptedAt: new Date(), updatedAt: new Date() })
526
+ .where(eq(invites.id, invite.id))
527
+ .returning()
528
+ .then((rows) => rows[0] ?? invite);
529
+ res.status(202).json({
530
+ inviteId: updatedInvite.id,
531
+ inviteType: updatedInvite.inviteType,
532
+ bootstrapAccepted: true,
533
+ userId,
534
+ });
535
+ return;
536
+ }
537
+ const requestType = req.body.requestType;
538
+ const companyId = invite.companyId;
539
+ if (!companyId)
540
+ throw conflict("Invite is missing company scope");
541
+ if (invite.allowedJoinTypes !== "both" && invite.allowedJoinTypes !== requestType) {
542
+ throw badRequest(`Invite does not allow ${requestType} joins`);
543
+ }
544
+ if (requestType === "human" && req.actor.type !== "board") {
545
+ throw unauthorized("Human invite acceptance requires authenticated user");
546
+ }
547
+ if (requestType === "human" && !req.actor.userId && !isLocalImplicit(req)) {
548
+ throw unauthorized("Authenticated user is required");
549
+ }
550
+ if (requestType === "agent" && !req.body.agentName) {
551
+ throw badRequest("agentName is required for agent join requests");
552
+ }
553
+ const joinDefaults = requestType === "agent"
554
+ ? normalizeAgentDefaultsForJoin({
555
+ adapterType: req.body.adapterType ?? null,
556
+ defaultsPayload: req.body.agentDefaultsPayload ?? null,
557
+ deploymentMode: opts.deploymentMode,
558
+ deploymentExposure: opts.deploymentExposure,
559
+ bindHost: opts.bindHost,
560
+ allowedHostnames: opts.allowedHostnames,
561
+ })
562
+ : { normalized: null, diagnostics: [] };
563
+ const claimSecret = requestType === "agent" ? createClaimSecret() : null;
564
+ const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
565
+ const claimSecretExpiresAt = claimSecret
566
+ ? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
567
+ : null;
568
+ const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null;
569
+ const created = await db.transaction(async (tx) => {
570
+ await tx
571
+ .update(invites)
572
+ .set({ acceptedAt: new Date(), updatedAt: new Date() })
573
+ .where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt)));
574
+ const row = await tx
575
+ .insert(joinRequests)
576
+ .values({
577
+ inviteId: invite.id,
578
+ companyId,
579
+ requestType,
580
+ status: "pending_approval",
581
+ requestIp: requestIp(req),
582
+ requestingUserId: requestType === "human" ? req.actor.userId ?? "local-board" : null,
583
+ requestEmailSnapshot: requestType === "human" ? actorEmail : null,
584
+ agentName: requestType === "agent" ? req.body.agentName : null,
585
+ adapterType: requestType === "agent" ? req.body.adapterType ?? null : null,
586
+ capabilities: requestType === "agent" ? req.body.capabilities ?? null : null,
587
+ agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null,
588
+ claimSecretHash,
589
+ claimSecretExpiresAt,
590
+ })
591
+ .returning()
592
+ .then((rows) => rows[0]);
593
+ return row;
594
+ });
595
+ await logActivity(db, {
596
+ companyId,
597
+ actorType: req.actor.type === "agent" ? "agent" : "user",
598
+ actorId: req.actor.type === "agent"
599
+ ? req.actor.agentId ?? "invite-agent"
600
+ : req.actor.userId ?? (requestType === "agent" ? "invite-anon" : "board"),
601
+ action: "join.requested",
602
+ entityType: "join_request",
603
+ entityId: created.id,
604
+ details: { requestType, requestIp: created.requestIp },
605
+ });
606
+ const response = toJoinRequestResponse(created);
607
+ if (claimSecret) {
608
+ const onboardingManifest = buildInviteOnboardingManifest(req, token, invite, opts);
609
+ res.status(202).json({
610
+ ...response,
611
+ claimSecret,
612
+ claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`,
613
+ onboarding: onboardingManifest.onboarding,
614
+ diagnostics: joinDefaults.diagnostics,
615
+ });
616
+ return;
617
+ }
618
+ res.status(202).json({
619
+ ...response,
620
+ ...(joinDefaults.diagnostics.length > 0 ? { diagnostics: joinDefaults.diagnostics } : {}),
621
+ });
622
+ });
623
+ router.post("/invites/:inviteId/revoke", async (req, res) => {
624
+ const id = req.params.inviteId;
625
+ const invite = await db.select().from(invites).where(eq(invites.id, id)).then((rows) => rows[0] ?? null);
626
+ if (!invite)
627
+ throw notFound("Invite not found");
628
+ if (invite.inviteType === "bootstrap_ceo") {
629
+ await assertInstanceAdmin(req);
630
+ }
631
+ else {
632
+ if (!invite.companyId)
633
+ throw conflict("Invite is missing company scope");
634
+ await assertCompanyPermission(req, invite.companyId, "users:invite");
635
+ }
636
+ if (invite.acceptedAt)
637
+ throw conflict("Invite already consumed");
638
+ if (invite.revokedAt)
639
+ return res.json(invite);
640
+ const revoked = await db
641
+ .update(invites)
642
+ .set({ revokedAt: new Date(), updatedAt: new Date() })
643
+ .where(eq(invites.id, id))
644
+ .returning()
645
+ .then((rows) => rows[0]);
646
+ if (invite.companyId) {
647
+ await logActivity(db, {
648
+ companyId: invite.companyId,
649
+ actorType: req.actor.type === "agent" ? "agent" : "user",
650
+ actorId: req.actor.type === "agent" ? req.actor.agentId ?? "unknown-agent" : req.actor.userId ?? "board",
651
+ action: "invite.revoked",
652
+ entityType: "invite",
653
+ entityId: id,
654
+ });
655
+ }
656
+ res.json(revoked);
657
+ });
658
+ router.get("/companies/:companyId/join-requests", async (req, res) => {
659
+ const companyId = req.params.companyId;
660
+ await assertCompanyPermission(req, companyId, "joins:approve");
661
+ const query = listJoinRequestsQuerySchema.parse(req.query);
662
+ const all = await db
663
+ .select()
664
+ .from(joinRequests)
665
+ .where(eq(joinRequests.companyId, companyId))
666
+ .orderBy(desc(joinRequests.createdAt));
667
+ const filtered = all.filter((row) => {
668
+ if (query.status && row.status !== query.status)
669
+ return false;
670
+ if (query.requestType && row.requestType !== query.requestType)
671
+ return false;
672
+ return true;
673
+ });
674
+ res.json(filtered.map(toJoinRequestResponse));
675
+ });
676
+ router.post("/companies/:companyId/join-requests/:requestId/approve", async (req, res) => {
677
+ const companyId = req.params.companyId;
678
+ const requestId = req.params.requestId;
679
+ await assertCompanyPermission(req, companyId, "joins:approve");
680
+ const existing = await db
681
+ .select()
682
+ .from(joinRequests)
683
+ .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId)))
684
+ .then((rows) => rows[0] ?? null);
685
+ if (!existing)
686
+ throw notFound("Join request not found");
687
+ if (existing.status !== "pending_approval")
688
+ throw conflict("Join request is not pending");
689
+ const invite = await db
690
+ .select()
691
+ .from(invites)
692
+ .where(eq(invites.id, existing.inviteId))
693
+ .then((rows) => rows[0] ?? null);
694
+ if (!invite)
695
+ throw notFound("Invite not found");
696
+ let createdAgentId = existing.createdAgentId ?? null;
697
+ if (existing.requestType === "human") {
698
+ if (!existing.requestingUserId)
699
+ throw conflict("Join request missing user identity");
700
+ await access.ensureMembership(companyId, "user", existing.requestingUserId, "member", "active");
701
+ const grants = grantsFromDefaults(invite.defaultsPayload, "human");
702
+ await access.setPrincipalGrants(companyId, "user", existing.requestingUserId, grants, req.actor.userId ?? null);
703
+ }
704
+ else {
705
+ const created = await agents.create(companyId, {
706
+ name: existing.agentName ?? "New Agent",
707
+ role: "general",
708
+ title: null,
709
+ status: "idle",
710
+ reportsTo: null,
711
+ capabilities: existing.capabilities ?? null,
712
+ adapterType: existing.adapterType ?? "process",
713
+ adapterConfig: existing.agentDefaultsPayload && typeof existing.agentDefaultsPayload === "object"
714
+ ? existing.agentDefaultsPayload
715
+ : {},
716
+ runtimeConfig: {},
717
+ budgetMonthlyCents: 0,
718
+ spentMonthlyCents: 0,
719
+ permissions: {},
720
+ lastHeartbeatAt: null,
721
+ metadata: null,
722
+ });
723
+ createdAgentId = created.id;
724
+ await access.ensureMembership(companyId, "agent", created.id, "member", "active");
725
+ const grants = grantsFromDefaults(invite.defaultsPayload, "agent");
726
+ await access.setPrincipalGrants(companyId, "agent", created.id, grants, req.actor.userId ?? null);
727
+ }
728
+ const approved = await db
729
+ .update(joinRequests)
730
+ .set({
731
+ status: "approved",
732
+ approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
733
+ approvedAt: new Date(),
734
+ createdAgentId,
735
+ updatedAt: new Date(),
736
+ })
737
+ .where(eq(joinRequests.id, requestId))
738
+ .returning()
739
+ .then((rows) => rows[0]);
740
+ await logActivity(db, {
741
+ companyId,
742
+ actorType: "user",
743
+ actorId: req.actor.userId ?? "board",
744
+ action: "join.approved",
745
+ entityType: "join_request",
746
+ entityId: requestId,
747
+ details: { requestType: existing.requestType, createdAgentId },
748
+ });
749
+ res.json(toJoinRequestResponse(approved));
750
+ });
751
+ router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => {
752
+ const companyId = req.params.companyId;
753
+ const requestId = req.params.requestId;
754
+ await assertCompanyPermission(req, companyId, "joins:approve");
755
+ const existing = await db
756
+ .select()
757
+ .from(joinRequests)
758
+ .where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.id, requestId)))
759
+ .then((rows) => rows[0] ?? null);
760
+ if (!existing)
761
+ throw notFound("Join request not found");
762
+ if (existing.status !== "pending_approval")
763
+ throw conflict("Join request is not pending");
764
+ const rejected = await db
765
+ .update(joinRequests)
766
+ .set({
767
+ status: "rejected",
768
+ rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
769
+ rejectedAt: new Date(),
770
+ updatedAt: new Date(),
771
+ })
772
+ .where(eq(joinRequests.id, requestId))
773
+ .returning()
774
+ .then((rows) => rows[0]);
775
+ await logActivity(db, {
776
+ companyId,
777
+ actorType: "user",
778
+ actorId: req.actor.userId ?? "board",
779
+ action: "join.rejected",
780
+ entityType: "join_request",
781
+ entityId: requestId,
782
+ details: { requestType: existing.requestType },
783
+ });
784
+ res.json(toJoinRequestResponse(rejected));
785
+ });
786
+ router.post("/join-requests/:requestId/claim-api-key", validate(claimJoinRequestApiKeySchema), async (req, res) => {
787
+ const requestId = req.params.requestId;
788
+ const presentedClaimSecretHash = hashToken(req.body.claimSecret);
789
+ const joinRequest = await db
790
+ .select()
791
+ .from(joinRequests)
792
+ .where(eq(joinRequests.id, requestId))
793
+ .then((rows) => rows[0] ?? null);
794
+ if (!joinRequest)
795
+ throw notFound("Join request not found");
796
+ if (joinRequest.requestType !== "agent")
797
+ throw badRequest("Only agent join requests can claim API keys");
798
+ if (joinRequest.status !== "approved")
799
+ throw conflict("Join request must be approved before key claim");
800
+ if (!joinRequest.createdAgentId)
801
+ throw conflict("Join request has no created agent");
802
+ if (!joinRequest.claimSecretHash)
803
+ throw conflict("Join request is missing claim secret metadata");
804
+ if (!tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash)) {
805
+ throw forbidden("Invalid claim secret");
806
+ }
807
+ if (joinRequest.claimSecretExpiresAt && joinRequest.claimSecretExpiresAt.getTime() <= Date.now()) {
808
+ throw conflict("Claim secret expired");
809
+ }
810
+ if (joinRequest.claimSecretConsumedAt)
811
+ throw conflict("Claim secret already used");
812
+ const existingKey = await db
813
+ .select({ id: agentApiKeys.id })
814
+ .from(agentApiKeys)
815
+ .where(eq(agentApiKeys.agentId, joinRequest.createdAgentId))
816
+ .then((rows) => rows[0] ?? null);
817
+ if (existingKey)
818
+ throw conflict("API key already claimed");
819
+ const consumed = await db
820
+ .update(joinRequests)
821
+ .set({ claimSecretConsumedAt: new Date(), updatedAt: new Date() })
822
+ .where(and(eq(joinRequests.id, requestId), isNull(joinRequests.claimSecretConsumedAt)))
823
+ .returning({ id: joinRequests.id })
824
+ .then((rows) => rows[0] ?? null);
825
+ if (!consumed)
826
+ throw conflict("Claim secret already used");
827
+ const created = await agents.createApiKey(joinRequest.createdAgentId, "initial-join-key");
828
+ await logActivity(db, {
829
+ companyId: joinRequest.companyId,
830
+ actorType: "system",
831
+ actorId: "join-claim",
832
+ action: "agent_api_key.claimed",
833
+ entityType: "agent_api_key",
834
+ entityId: created.id,
835
+ details: { agentId: joinRequest.createdAgentId, joinRequestId: requestId },
836
+ });
837
+ res.status(201).json({
838
+ keyId: created.id,
839
+ token: created.token,
840
+ agentId: joinRequest.createdAgentId,
841
+ createdAt: created.createdAt,
842
+ });
843
+ });
844
+ router.get("/companies/:companyId/members", async (req, res) => {
845
+ const companyId = req.params.companyId;
846
+ await assertCompanyPermission(req, companyId, "users:manage_permissions");
847
+ const members = await access.listMembers(companyId);
848
+ res.json(members);
849
+ });
850
+ router.patch("/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), async (req, res) => {
851
+ const companyId = req.params.companyId;
852
+ const memberId = req.params.memberId;
853
+ await assertCompanyPermission(req, companyId, "users:manage_permissions");
854
+ const updated = await access.setMemberPermissions(companyId, memberId, req.body.grants ?? [], req.actor.userId ?? null);
855
+ if (!updated)
856
+ throw notFound("Member not found");
857
+ res.json(updated);
858
+ });
859
+ router.post("/admin/users/:userId/promote-instance-admin", async (req, res) => {
860
+ await assertInstanceAdmin(req);
861
+ const userId = req.params.userId;
862
+ const result = await access.promoteInstanceAdmin(userId);
863
+ res.status(201).json(result);
864
+ });
865
+ router.post("/admin/users/:userId/demote-instance-admin", async (req, res) => {
866
+ await assertInstanceAdmin(req);
867
+ const userId = req.params.userId;
868
+ const removed = await access.demoteInstanceAdmin(userId);
869
+ if (!removed)
870
+ throw notFound("Instance admin role not found");
871
+ res.json(removed);
872
+ });
873
+ router.get("/admin/users/:userId/company-access", async (req, res) => {
874
+ await assertInstanceAdmin(req);
875
+ const userId = req.params.userId;
876
+ const memberships = await access.listUserCompanyAccess(userId);
877
+ res.json(memberships);
878
+ });
879
+ router.put("/admin/users/:userId/company-access", validate(updateUserCompanyAccessSchema), async (req, res) => {
880
+ await assertInstanceAdmin(req);
881
+ const userId = req.params.userId;
882
+ const memberships = await access.setUserCompanyAccess(userId, req.body.companyIds ?? []);
883
+ res.json(memberships);
884
+ });
885
+ return router;
886
+ }
887
+ //# sourceMappingURL=access.js.map