@paperclipai/server 0.2.7 → 0.3.0-canary.1
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/LICENSE +21 -0
- package/dist/adapters/cursor-models.d.ts +13 -0
- package/dist/adapters/cursor-models.d.ts.map +1 -0
- package/dist/adapters/cursor-models.js +148 -0
- package/dist/adapters/cursor-models.js.map +1 -0
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/registry.js +55 -9
- package/dist/adapters/registry.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +5 -1
- package/dist/app.js.map +1 -1
- package/dist/auth/better-auth.d.ts +2 -1
- package/dist/auth/better-auth.d.ts.map +1 -1
- package/dist/auth/better-auth.js +29 -1
- package/dist/auth/better-auth.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -2
- package/dist/config.js.map +1 -1
- package/dist/home-paths.d.ts +1 -0
- package/dist/home-paths.d.ts.map +1 -1
- package/dist/home-paths.js +3 -0
- package/dist/home-paths.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +468 -370
- package/dist/index.js.map +1 -1
- package/dist/middleware/error-handler.d.ts +14 -0
- package/dist/middleware/error-handler.d.ts.map +1 -1
- package/dist/middleware/error-handler.js +19 -4
- package/dist/middleware/error-handler.js.map +1 -1
- package/dist/middleware/logger.d.ts.map +1 -1
- package/dist/middleware/logger.js +52 -3
- package/dist/middleware/logger.js.map +1 -1
- package/dist/realtime/live-events-ws.d.ts +20 -2
- package/dist/realtime/live-events-ws.d.ts.map +1 -1
- package/dist/realtime/live-events-ws.js +3 -1
- package/dist/realtime/live-events-ws.js.map +1 -1
- package/dist/routes/access.d.ts +47 -0
- package/dist/routes/access.d.ts.map +1 -1
- package/dist/routes/access.js +1340 -193
- package/dist/routes/access.js.map +1 -1
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +106 -17
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/companies.d.ts.map +1 -1
- package/dist/routes/companies.js +6 -0
- package/dist/routes/companies.js.map +1 -1
- package/dist/routes/issues-checkout-wakeup.d.ts +9 -0
- package/dist/routes/issues-checkout-wakeup.d.ts.map +1 -0
- package/dist/routes/issues-checkout-wakeup.js +12 -0
- package/dist/routes/issues-checkout-wakeup.js.map +1 -0
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +122 -15
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/sidebar-badges.d.ts.map +1 -1
- package/dist/routes/sidebar-badges.js +12 -11
- package/dist/routes/sidebar-badges.js.map +1 -1
- package/dist/services/agents.d.ts +13 -1
- package/dist/services/agents.d.ts.map +1 -1
- package/dist/services/agents.js +65 -5
- package/dist/services/agents.js.map +1 -1
- package/dist/services/approvals.d.ts.map +1 -1
- package/dist/services/approvals.js +14 -1
- package/dist/services/approvals.js.map +1 -1
- package/dist/services/company-portability.d.ts.map +1 -1
- package/dist/services/company-portability.js +16 -4
- package/dist/services/company-portability.js.map +1 -1
- package/dist/services/heartbeat.d.ts +24 -0
- package/dist/services/heartbeat.d.ts.map +1 -1
- package/dist/services/heartbeat.js +152 -9
- package/dist/services/heartbeat.js.map +1 -1
- package/dist/services/hire-hook.d.ts +14 -0
- package/dist/services/hire-hook.d.ts.map +1 -0
- package/dist/services/hire-hook.js +85 -0
- package/dist/services/hire-hook.js.map +1 -0
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/issues.d.ts +37 -0
- package/dist/services/issues.d.ts.map +1 -1
- package/dist/services/issues.js +199 -2
- package/dist/services/issues.js.map +1 -1
- package/dist/services/projects.d.ts +8 -0
- package/dist/services/projects.d.ts.map +1 -1
- package/dist/services/projects.js +45 -0
- package/dist/services/projects.js.map +1 -1
- package/dist/services/run-log-store.d.ts.map +1 -1
- package/dist/services/run-log-store.js +4 -7
- package/dist/services/run-log-store.js.map +1 -1
- package/dist/services/secrets.d.ts +6 -2
- package/dist/services/secrets.d.ts.map +1 -1
- package/dist/services/secrets.js +9 -5
- package/dist/services/secrets.js.map +1 -1
- package/dist/services/sidebar-badges.d.ts +1 -1
- package/dist/services/sidebar-badges.d.ts.map +1 -1
- package/dist/services/sidebar-badges.js +2 -2
- package/dist/services/sidebar-badges.js.map +1 -1
- package/dist/startup-banner.d.ts +4 -0
- package/dist/startup-banner.d.ts.map +1 -1
- package/dist/startup-banner.js +5 -0
- package/dist/startup-banner.js.map +1 -1
- package/package.json +15 -8
- package/skills/paperclip/SKILL.md +77 -10
- package/skills/paperclip/references/api-reference.md +24 -3
- package/skills/release/SKILL.md +261 -0
- package/skills/release-changelog/SKILL.md +178 -0
- package/ui-dist/assets/_basePickBy-uTypp8IS.js +1 -0
- package/ui-dist/assets/_baseUniq-Br5ginSL.js +1 -0
- package/ui-dist/assets/arc-CL_yTLb7.js +1 -0
- package/ui-dist/assets/architectureDiagram-VXUJARFQ-QDWenfc8.js +36 -0
- package/ui-dist/assets/blockDiagram-VD42YOAC-Cx5v00JC.js +122 -0
- package/ui-dist/assets/c4Diagram-YG6GDRKO-w1jXPcld.js +10 -0
- package/ui-dist/assets/channel-Cgg5Zy18.js +1 -0
- package/ui-dist/assets/chunk-4BX2VUAB-IP20JmJc.js +1 -0
- package/ui-dist/assets/chunk-55IACEB6-DnoDQzkr.js +1 -0
- package/ui-dist/assets/chunk-B4BG7PRW-B0oMPqWG.js +165 -0
- package/ui-dist/assets/chunk-DI55MBZ5-XP2Bv90U.js +220 -0
- package/ui-dist/assets/chunk-FMBD7UC4-BTXZhgrQ.js +15 -0
- package/ui-dist/assets/chunk-QN33PNHL-CV1_kZb0.js +1 -0
- package/ui-dist/assets/chunk-QZHKN3VN-DRQRsvpN.js +1 -0
- package/ui-dist/assets/chunk-TZMSLE5B-CyRhnMtV.js +1 -0
- package/ui-dist/assets/classDiagram-2ON5EDUG-C7fyGn2Z.js +1 -0
- package/ui-dist/assets/classDiagram-v2-WZHVMYZB-C7fyGn2Z.js +1 -0
- package/ui-dist/assets/clone-C-dKMXxT.js +1 -0
- package/ui-dist/assets/cose-bilkent-S5V4N54A-C3zcDRja.js +1 -0
- package/ui-dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/ui-dist/assets/dagre-6UL2VRFP-B2DRzkCD.js +4 -0
- package/ui-dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/ui-dist/assets/diagram-PSM6KHXK-CxoAcsrX.js +24 -0
- package/ui-dist/assets/diagram-QEK2KX5R-DE0jOoKl.js +43 -0
- package/ui-dist/assets/diagram-S2PKOQOG-nZkpfo7y.js +24 -0
- package/ui-dist/assets/erDiagram-Q2GNP2WA-Bl8ZbdFt.js +60 -0
- package/ui-dist/assets/flowDiagram-NV44I4VS-Bets3UXj.js +162 -0
- package/ui-dist/assets/ganttDiagram-JELNMOA3-CzdlM4Q4.js +267 -0
- package/ui-dist/assets/gitGraphDiagram-V2S2FVAM-0znuWjdT.js +65 -0
- package/ui-dist/assets/graph-CqfQ17fA.js +1 -0
- package/ui-dist/assets/{index-pu8fzL7q.js → index-7nNiKIK4.js} +2 -2
- package/ui-dist/assets/{index-CF8nX1vG.js → index-9_NCEtxm.js} +1 -1
- package/ui-dist/assets/{index-kYgLZCCi.js → index-B98qVBYv.js} +1 -1
- package/ui-dist/assets/{index-CdX_ZDP5.js → index-BGTZFghP.js} +1 -1
- package/ui-dist/assets/{index-tZqt0pgN.js → index-BLzuntY1.js} +5 -5
- package/ui-dist/assets/index-BZyxDGuR.js +1 -0
- package/ui-dist/assets/{index-NaKAsGKV.js → index-B_BkZUzM.js} +1 -1
- package/ui-dist/assets/{index-Dg1rynkq.js → index-Bgr40E5T.js} +1 -1
- package/ui-dist/assets/{index-B-OLFaqv.js → index-BrUI189T.js} +2 -2
- package/ui-dist/assets/{index-C9NgpAmN.js → index-BwywxTyu.js} +1 -1
- package/ui-dist/assets/{index-CgAKYlm4.js → index-C0rbEf43.js} +1 -1
- package/ui-dist/assets/{index-D-j8BRho.js → index-CEG1A6xN.js} +1 -1
- package/ui-dist/assets/{index-DYE3wRax.js → index-CZlgUP5H.js} +1 -1
- package/ui-dist/assets/{index-C3Iv_Fqi.js → index-CnZTY1ys.js} +1 -1
- package/ui-dist/assets/index-DN1_92Qm.js +900 -0
- package/ui-dist/assets/{index-COdba80T.js → index-DYxzi5jO.js} +1 -1
- package/ui-dist/assets/{index-BIEaSsd6.js → index-DZTlxw-9.js} +1 -1
- package/ui-dist/assets/{index-CKNdC7J5.js → index-DZYyOzky.js} +1 -1
- package/ui-dist/assets/{index-COhpE0rU.js → index-Dsg_WOwh.js} +1 -1
- package/ui-dist/assets/{index-Btnrtcrc.js → index-DskiIzZI.js} +1 -1
- package/ui-dist/assets/{index-jVC6cnfD.js → index-Lr8m8V8u.js} +1 -1
- package/ui-dist/assets/{index-BTMR1V3x.js → index-O7wFYmP6.js} +1 -1
- package/ui-dist/assets/index-nfAtmpEH.css +1 -0
- package/ui-dist/assets/{index-DVW9_Opq.js → index-rYTF_5JN.js} +1 -1
- package/ui-dist/assets/infoDiagram-HS3SLOUP-CoLHBo93.js +2 -0
- package/ui-dist/assets/init-Gi6I4Gst.js +1 -0
- package/ui-dist/assets/journeyDiagram-XKPGCS4Q-B-juOJt7.js +139 -0
- package/ui-dist/assets/kanban-definition-3W4ZIXB7-CRhCTAGl.js +89 -0
- package/ui-dist/assets/katex-O9d3_IXG.js +261 -0
- package/ui-dist/assets/layout-0_5Wah8d.js +1 -0
- package/ui-dist/assets/linear-rXBW0wlX.js +1 -0
- package/ui-dist/assets/mermaid.core-CZKXdC_e.js +256 -0
- package/ui-dist/assets/mindmap-definition-VGOIOE7T-CcsJM-Ch.js +68 -0
- package/ui-dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/ui-dist/assets/pieDiagram-ADFJNKIX-Ci6R42sU.js +30 -0
- package/ui-dist/assets/quadrantDiagram-AYHSOK5B-B6xrkCsR.js +7 -0
- package/ui-dist/assets/requirementDiagram-UZGBJVZJ-4V-WQI-N.js +64 -0
- package/ui-dist/assets/sankeyDiagram-TZEHDZUN-D4NrQ_iq.js +10 -0
- package/ui-dist/assets/sequenceDiagram-WL72ISMW-C1V8T_WD.js +145 -0
- package/ui-dist/assets/stateDiagram-FKZM4ZOC-B6msJabL.js +1 -0
- package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-BbXAbFox.js +1 -0
- package/ui-dist/assets/timeline-definition-IT6M3QCI-D82F3vGD.js +61 -0
- package/ui-dist/assets/treemap-GDKQZRPO-CN1NndeP.js +162 -0
- package/ui-dist/assets/xychartDiagram-PRI3JC2R-BrHVvbst.js +7 -0
- package/ui-dist/brands/opencode-logo-dark-square.svg +18 -0
- package/ui-dist/brands/opencode-logo-light-square.svg +18 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/site.webmanifest +15 -4
- package/ui-dist/sw.js +42 -0
- package/ui-dist/assets/index-B6IJ7rtH.css +0 -1
- package/ui-dist/assets/index-CNeWfnNw.js +0 -856
- package/ui-dist/assets/index-D7c99xP8.js +0 -1
package/dist/routes/access.js
CHANGED
|
@@ -1,29 +1,44 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHash, generateKeyPairSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Router } from "express";
|
|
6
6
|
import { and, eq, isNull, desc } from "drizzle-orm";
|
|
7
|
-
import { agentApiKeys, authUsers, invites, joinRequests
|
|
8
|
-
import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS
|
|
7
|
+
import { agentApiKeys, authUsers, invites, joinRequests } from "@paperclipai/db";
|
|
8
|
+
import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS } from "@paperclipai/shared";
|
|
9
9
|
import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js";
|
|
10
|
+
import { logger } from "../middleware/logger.js";
|
|
10
11
|
import { validate } from "../middleware/validate.js";
|
|
11
|
-
import { accessService, agentService, logActivity } from "../services/index.js";
|
|
12
|
+
import { accessService, agentService, deduplicateAgentName, logActivity, notifyHireApproved } from "../services/index.js";
|
|
12
13
|
import { assertCompanyAccess } from "./authz.js";
|
|
13
14
|
import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js";
|
|
14
15
|
function hashToken(token) {
|
|
15
16
|
return createHash("sha256").update(token).digest("hex");
|
|
16
17
|
}
|
|
18
|
+
const INVITE_TOKEN_PREFIX = "pcp_invite_";
|
|
19
|
+
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
20
|
+
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
|
21
|
+
const INVITE_TOKEN_MAX_RETRIES = 5;
|
|
22
|
+
const COMPANY_INVITE_TTL_MS = 10 * 60 * 1000;
|
|
17
23
|
function createInviteToken() {
|
|
18
|
-
|
|
24
|
+
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
|
|
25
|
+
let suffix = "";
|
|
26
|
+
for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
|
|
27
|
+
suffix += INVITE_TOKEN_ALPHABET[bytes[idx] % INVITE_TOKEN_ALPHABET.length];
|
|
28
|
+
}
|
|
29
|
+
return `${INVITE_TOKEN_PREFIX}${suffix}`;
|
|
19
30
|
}
|
|
20
31
|
function createClaimSecret() {
|
|
21
32
|
return `pcp_claim_${randomBytes(24).toString("hex")}`;
|
|
22
33
|
}
|
|
34
|
+
export function companyInviteExpiresAt(nowMs = Date.now()) {
|
|
35
|
+
return new Date(nowMs + COMPANY_INVITE_TTL_MS);
|
|
36
|
+
}
|
|
23
37
|
function tokenHashesMatch(left, right) {
|
|
24
38
|
const leftBytes = Buffer.from(left, "utf8");
|
|
25
39
|
const rightBytes = Buffer.from(right, "utf8");
|
|
26
|
-
return leftBytes.length === rightBytes.length &&
|
|
40
|
+
return (leftBytes.length === rightBytes.length &&
|
|
41
|
+
timingSafeEqual(leftBytes, rightBytes));
|
|
27
42
|
}
|
|
28
43
|
function requestBaseUrl(req) {
|
|
29
44
|
const forwardedProto = req.header("x-forwarded-proto");
|
|
@@ -41,7 +56,7 @@ function readSkillMarkdown(skillName) {
|
|
|
41
56
|
const candidates = [
|
|
42
57
|
path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> <pkg>/skills/
|
|
43
58
|
path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root)
|
|
44
|
-
path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md")
|
|
59
|
+
path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md") // dev: src/routes/ -> repo root/skills/
|
|
45
60
|
];
|
|
46
61
|
for (const skillPath of candidates) {
|
|
47
62
|
try {
|
|
@@ -72,170 +87,502 @@ function normalizeHostname(value) {
|
|
|
72
87
|
return null;
|
|
73
88
|
if (trimmed.startsWith("[")) {
|
|
74
89
|
const end = trimmed.indexOf("]");
|
|
75
|
-
return end > 1
|
|
90
|
+
return end > 1
|
|
91
|
+
? trimmed.slice(1, end).toLowerCase()
|
|
92
|
+
: trimmed.toLowerCase();
|
|
76
93
|
}
|
|
77
94
|
const firstColon = trimmed.indexOf(":");
|
|
78
95
|
if (firstColon > -1)
|
|
79
96
|
return trimmed.slice(0, firstColon).toLowerCase();
|
|
80
97
|
return trimmed.toLowerCase();
|
|
81
98
|
}
|
|
99
|
+
function normalizeHeaderValue(value, depth = 0) {
|
|
100
|
+
const direct = nonEmptyTrimmedString(value);
|
|
101
|
+
if (direct)
|
|
102
|
+
return direct;
|
|
103
|
+
if (!isPlainObject(value) || depth >= 3)
|
|
104
|
+
return null;
|
|
105
|
+
const candidateKeys = [
|
|
106
|
+
"value",
|
|
107
|
+
"token",
|
|
108
|
+
"secret",
|
|
109
|
+
"apiKey",
|
|
110
|
+
"api_key",
|
|
111
|
+
"auth",
|
|
112
|
+
"authToken",
|
|
113
|
+
"auth_token",
|
|
114
|
+
"accessToken",
|
|
115
|
+
"access_token",
|
|
116
|
+
"authorization",
|
|
117
|
+
"bearer",
|
|
118
|
+
"header",
|
|
119
|
+
"raw",
|
|
120
|
+
"text",
|
|
121
|
+
"string"
|
|
122
|
+
];
|
|
123
|
+
for (const key of candidateKeys) {
|
|
124
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
125
|
+
continue;
|
|
126
|
+
const normalized = normalizeHeaderValue(value[key], depth + 1);
|
|
127
|
+
if (normalized)
|
|
128
|
+
return normalized;
|
|
129
|
+
}
|
|
130
|
+
const entries = Object.entries(value);
|
|
131
|
+
if (entries.length === 1) {
|
|
132
|
+
const [singleKey, singleValue] = entries[0];
|
|
133
|
+
const normalizedKey = singleKey.trim().toLowerCase();
|
|
134
|
+
if (normalizedKey !== "type" &&
|
|
135
|
+
normalizedKey !== "version" &&
|
|
136
|
+
normalizedKey !== "secretid" &&
|
|
137
|
+
normalizedKey !== "secret_id") {
|
|
138
|
+
const normalized = normalizeHeaderValue(singleValue, depth + 1);
|
|
139
|
+
if (normalized)
|
|
140
|
+
return normalized;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
function extractHeaderEntries(input) {
|
|
146
|
+
if (isPlainObject(input)) {
|
|
147
|
+
return Object.entries(input);
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(input)) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const entries = [];
|
|
153
|
+
for (const item of input) {
|
|
154
|
+
if (Array.isArray(item)) {
|
|
155
|
+
const key = nonEmptyTrimmedString(item[0]);
|
|
156
|
+
if (!key)
|
|
157
|
+
continue;
|
|
158
|
+
entries.push([key, item[1]]);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!isPlainObject(item))
|
|
162
|
+
continue;
|
|
163
|
+
const mapped = item;
|
|
164
|
+
const explicitKey = nonEmptyTrimmedString(mapped.key) ??
|
|
165
|
+
nonEmptyTrimmedString(mapped.name) ??
|
|
166
|
+
nonEmptyTrimmedString(mapped.header);
|
|
167
|
+
if (explicitKey) {
|
|
168
|
+
const explicitValue = Object.prototype.hasOwnProperty.call(mapped, "value")
|
|
169
|
+
? mapped.value
|
|
170
|
+
: Object.prototype.hasOwnProperty.call(mapped, "token")
|
|
171
|
+
? mapped.token
|
|
172
|
+
: Object.prototype.hasOwnProperty.call(mapped, "secret")
|
|
173
|
+
? mapped.secret
|
|
174
|
+
: mapped;
|
|
175
|
+
entries.push([explicitKey, explicitValue]);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const singleEntry = Object.entries(mapped);
|
|
179
|
+
if (singleEntry.length === 1) {
|
|
180
|
+
entries.push(singleEntry[0]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return entries;
|
|
184
|
+
}
|
|
82
185
|
function normalizeHeaderMap(input) {
|
|
83
|
-
|
|
186
|
+
const entries = extractHeaderEntries(input);
|
|
187
|
+
if (entries.length === 0)
|
|
84
188
|
return undefined;
|
|
85
189
|
const out = {};
|
|
86
|
-
for (const [key, value] of
|
|
87
|
-
|
|
190
|
+
for (const [key, value] of entries) {
|
|
191
|
+
const normalizedValue = normalizeHeaderValue(value);
|
|
192
|
+
if (!normalizedValue)
|
|
88
193
|
continue;
|
|
89
194
|
const trimmedKey = key.trim();
|
|
90
|
-
const trimmedValue =
|
|
195
|
+
const trimmedValue = normalizedValue.trim();
|
|
91
196
|
if (!trimmedKey || !trimmedValue)
|
|
92
197
|
continue;
|
|
93
198
|
out[trimmedKey] = trimmedValue;
|
|
94
199
|
}
|
|
95
200
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
96
201
|
}
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`,
|
|
123
|
-
hint: `Run pnpm paperclipai allowed-hostname ${bindHost}`,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) {
|
|
127
|
-
diagnostics.push({
|
|
128
|
-
code: "openclaw_private_allowed_hostnames_empty",
|
|
129
|
-
level: "warn",
|
|
130
|
-
message: "No explicit allowed hostnames are configured for authenticated/private mode.",
|
|
131
|
-
hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs off-host.",
|
|
132
|
-
});
|
|
133
|
-
}
|
|
202
|
+
function nonEmptyTrimmedString(value) {
|
|
203
|
+
if (typeof value !== "string")
|
|
204
|
+
return null;
|
|
205
|
+
const trimmed = value.trim();
|
|
206
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
207
|
+
}
|
|
208
|
+
function headerMapHasKeyIgnoreCase(headers, targetKey) {
|
|
209
|
+
const normalizedTarget = targetKey.trim().toLowerCase();
|
|
210
|
+
return Object.keys(headers).some((key) => key.trim().toLowerCase() === normalizedTarget);
|
|
211
|
+
}
|
|
212
|
+
function headerMapGetIgnoreCase(headers, targetKey) {
|
|
213
|
+
const normalizedTarget = targetKey.trim().toLowerCase();
|
|
214
|
+
const key = Object.keys(headers).find((candidate) => candidate.trim().toLowerCase() === normalizedTarget);
|
|
215
|
+
if (!key)
|
|
216
|
+
return null;
|
|
217
|
+
const value = headers[key];
|
|
218
|
+
return typeof value === "string" ? value : null;
|
|
219
|
+
}
|
|
220
|
+
function tokenFromAuthorizationHeader(rawHeader) {
|
|
221
|
+
const trimmed = nonEmptyTrimmedString(rawHeader);
|
|
222
|
+
if (!trimmed)
|
|
223
|
+
return null;
|
|
224
|
+
const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i);
|
|
225
|
+
if (bearerMatch?.[1]) {
|
|
226
|
+
return nonEmptyTrimmedString(bearerMatch[1]);
|
|
134
227
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
228
|
+
return trimmed;
|
|
229
|
+
}
|
|
230
|
+
function parseBooleanLike(value) {
|
|
231
|
+
if (typeof value === "boolean")
|
|
232
|
+
return value;
|
|
233
|
+
if (typeof value !== "string")
|
|
234
|
+
return null;
|
|
235
|
+
const normalized = value.trim().toLowerCase();
|
|
236
|
+
if (normalized === "true" || normalized === "1")
|
|
237
|
+
return true;
|
|
238
|
+
if (normalized === "false" || normalized === "0")
|
|
239
|
+
return false;
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
function generateEd25519PrivateKeyPem() {
|
|
243
|
+
const generated = generateKeyPairSync("ed25519");
|
|
244
|
+
return generated.privateKey
|
|
245
|
+
.export({ type: "pkcs8", format: "pem" })
|
|
246
|
+
.toString();
|
|
247
|
+
}
|
|
248
|
+
export function buildJoinDefaultsPayloadForAccept(input) {
|
|
249
|
+
if (input.adapterType !== "openclaw_gateway") {
|
|
250
|
+
return input.defaultsPayload;
|
|
145
251
|
}
|
|
146
|
-
|
|
252
|
+
const merged = isPlainObject(input.defaultsPayload)
|
|
253
|
+
? { ...input.defaultsPayload }
|
|
254
|
+
: {};
|
|
255
|
+
if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) {
|
|
256
|
+
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
|
257
|
+
if (legacyPaperclipApiUrl)
|
|
258
|
+
merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
|
259
|
+
}
|
|
260
|
+
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
|
261
|
+
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(input.inboundOpenClawAuthHeader);
|
|
262
|
+
const inboundOpenClawTokenHeader = nonEmptyTrimmedString(input.inboundOpenClawTokenHeader);
|
|
263
|
+
if (inboundOpenClawTokenHeader &&
|
|
264
|
+
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")) {
|
|
265
|
+
mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader;
|
|
266
|
+
}
|
|
267
|
+
if (inboundOpenClawAuthHeader &&
|
|
268
|
+
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")) {
|
|
269
|
+
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
|
270
|
+
}
|
|
271
|
+
if (Object.keys(mergedHeaders).length > 0) {
|
|
272
|
+
merged.headers = mergedHeaders;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
delete merged.headers;
|
|
276
|
+
}
|
|
277
|
+
const discoveredToken = headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
|
278
|
+
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
|
|
279
|
+
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(mergedHeaders, "authorization"));
|
|
280
|
+
if (discoveredToken &&
|
|
281
|
+
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")) {
|
|
282
|
+
mergedHeaders["x-openclaw-token"] = discoveredToken;
|
|
283
|
+
}
|
|
284
|
+
return Object.keys(merged).length > 0 ? merged : null;
|
|
285
|
+
}
|
|
286
|
+
export function mergeJoinDefaultsPayloadForReplay(existingDefaultsPayload, nextDefaultsPayload) {
|
|
287
|
+
if (!isPlainObject(existingDefaultsPayload) &&
|
|
288
|
+
!isPlainObject(nextDefaultsPayload)) {
|
|
289
|
+
return nextDefaultsPayload ?? existingDefaultsPayload;
|
|
290
|
+
}
|
|
291
|
+
if (!isPlainObject(existingDefaultsPayload)) {
|
|
292
|
+
return nextDefaultsPayload;
|
|
293
|
+
}
|
|
294
|
+
if (!isPlainObject(nextDefaultsPayload)) {
|
|
295
|
+
return existingDefaultsPayload;
|
|
296
|
+
}
|
|
297
|
+
const merged = {
|
|
298
|
+
...existingDefaultsPayload,
|
|
299
|
+
...nextDefaultsPayload
|
|
300
|
+
};
|
|
301
|
+
const existingHeaders = normalizeHeaderMap(existingDefaultsPayload.headers);
|
|
302
|
+
const nextHeaders = normalizeHeaderMap(nextDefaultsPayload.headers);
|
|
303
|
+
if (existingHeaders || nextHeaders) {
|
|
304
|
+
merged.headers = {
|
|
305
|
+
...(existingHeaders ?? {}),
|
|
306
|
+
...(nextHeaders ?? {})
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
else if (Object.prototype.hasOwnProperty.call(merged, "headers")) {
|
|
310
|
+
delete merged.headers;
|
|
311
|
+
}
|
|
312
|
+
return merged;
|
|
313
|
+
}
|
|
314
|
+
export function canReplayOpenClawGatewayInviteAccept(input) {
|
|
315
|
+
if (input.requestType !== "agent" ||
|
|
316
|
+
input.adapterType !== "openclaw_gateway") {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (!input.existingJoinRequest) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (input.existingJoinRequest.requestType !== "agent" ||
|
|
323
|
+
input.existingJoinRequest.adapterType !== "openclaw_gateway") {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
return (input.existingJoinRequest.status === "pending_approval" ||
|
|
327
|
+
input.existingJoinRequest.status === "approved");
|
|
328
|
+
}
|
|
329
|
+
function summarizeSecretForLog(value) {
|
|
330
|
+
const trimmed = nonEmptyTrimmedString(value);
|
|
331
|
+
if (!trimmed)
|
|
332
|
+
return null;
|
|
333
|
+
return {
|
|
334
|
+
present: true,
|
|
335
|
+
length: trimmed.length,
|
|
336
|
+
sha256Prefix: hashToken(trimmed).slice(0, 12)
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload) {
|
|
340
|
+
const defaults = isPlainObject(defaultsPayload)
|
|
341
|
+
? defaultsPayload
|
|
342
|
+
: null;
|
|
343
|
+
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
|
|
344
|
+
const gatewayTokenValue = headers
|
|
345
|
+
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
|
346
|
+
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
|
347
|
+
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"))
|
|
348
|
+
: null;
|
|
349
|
+
return {
|
|
350
|
+
present: Boolean(defaults),
|
|
351
|
+
keys: defaults ? Object.keys(defaults).sort() : [],
|
|
352
|
+
url: defaults ? nonEmptyTrimmedString(defaults.url) : null,
|
|
353
|
+
paperclipApiUrl: defaults
|
|
354
|
+
? nonEmptyTrimmedString(defaults.paperclipApiUrl)
|
|
355
|
+
: null,
|
|
356
|
+
headerKeys: headers ? Object.keys(headers).sort() : [],
|
|
357
|
+
sessionKeyStrategy: defaults
|
|
358
|
+
? nonEmptyTrimmedString(defaults.sessionKeyStrategy)
|
|
359
|
+
: null,
|
|
360
|
+
disableDeviceAuth: defaults
|
|
361
|
+
? parseBooleanLike(defaults.disableDeviceAuth)
|
|
362
|
+
: null,
|
|
363
|
+
waitTimeoutMs: defaults && typeof defaults.waitTimeoutMs === "number"
|
|
364
|
+
? defaults.waitTimeoutMs
|
|
365
|
+
: null,
|
|
366
|
+
devicePrivateKeyPem: defaults
|
|
367
|
+
? summarizeSecretForLog(defaults.devicePrivateKeyPem)
|
|
368
|
+
: null,
|
|
369
|
+
gatewayToken: summarizeSecretForLog(gatewayTokenValue)
|
|
370
|
+
};
|
|
147
371
|
}
|
|
148
|
-
function normalizeAgentDefaultsForJoin(input) {
|
|
372
|
+
export function normalizeAgentDefaultsForJoin(input) {
|
|
373
|
+
const fatalErrors = [];
|
|
149
374
|
const diagnostics = [];
|
|
150
|
-
if (input.adapterType !== "
|
|
375
|
+
if (input.adapterType !== "openclaw_gateway") {
|
|
151
376
|
const normalized = isPlainObject(input.defaultsPayload)
|
|
152
377
|
? input.defaultsPayload
|
|
153
378
|
: null;
|
|
154
|
-
return { normalized, diagnostics };
|
|
379
|
+
return { normalized, diagnostics, fatalErrors };
|
|
155
380
|
}
|
|
156
381
|
if (!isPlainObject(input.defaultsPayload)) {
|
|
157
382
|
diagnostics.push({
|
|
158
|
-
code: "
|
|
383
|
+
code: "openclaw_gateway_defaults_missing",
|
|
159
384
|
level: "warn",
|
|
160
|
-
message: "No OpenClaw
|
|
161
|
-
hint: "Include agentDefaultsPayload.url
|
|
385
|
+
message: "No OpenClaw gateway config was provided in agentDefaultsPayload.",
|
|
386
|
+
hint: "Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins."
|
|
162
387
|
});
|
|
163
|
-
|
|
388
|
+
fatalErrors.push("agentDefaultsPayload is required for adapterType=openclaw_gateway");
|
|
389
|
+
return {
|
|
390
|
+
normalized: null,
|
|
391
|
+
diagnostics,
|
|
392
|
+
fatalErrors
|
|
393
|
+
};
|
|
164
394
|
}
|
|
165
395
|
const defaults = input.defaultsPayload;
|
|
166
396
|
const normalized = {};
|
|
167
|
-
let
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
397
|
+
let gatewayUrl = null;
|
|
398
|
+
const rawGatewayUrl = nonEmptyTrimmedString(defaults.url);
|
|
399
|
+
if (!rawGatewayUrl) {
|
|
170
400
|
diagnostics.push({
|
|
171
|
-
code: "
|
|
401
|
+
code: "openclaw_gateway_url_missing",
|
|
172
402
|
level: "warn",
|
|
173
|
-
message: "OpenClaw
|
|
174
|
-
hint: "Set agentDefaultsPayload.url to
|
|
403
|
+
message: "OpenClaw gateway URL is missing.",
|
|
404
|
+
hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL."
|
|
175
405
|
});
|
|
406
|
+
fatalErrors.push("agentDefaultsPayload.url is required");
|
|
176
407
|
}
|
|
177
408
|
else {
|
|
178
409
|
try {
|
|
179
|
-
|
|
180
|
-
if (
|
|
410
|
+
gatewayUrl = new URL(rawGatewayUrl);
|
|
411
|
+
if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") {
|
|
181
412
|
diagnostics.push({
|
|
182
|
-
code: "
|
|
413
|
+
code: "openclaw_gateway_url_protocol",
|
|
183
414
|
level: "warn",
|
|
184
|
-
message: `
|
|
185
|
-
hint: "Use http:// or https://.",
|
|
415
|
+
message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).`
|
|
186
416
|
});
|
|
417
|
+
fatalErrors.push("agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway");
|
|
187
418
|
}
|
|
188
419
|
else {
|
|
189
|
-
normalized.url =
|
|
420
|
+
normalized.url = gatewayUrl.toString();
|
|
190
421
|
diagnostics.push({
|
|
191
|
-
code: "
|
|
422
|
+
code: "openclaw_gateway_url_configured",
|
|
192
423
|
level: "info",
|
|
193
|
-
message: `
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
if (isLoopbackHost(callbackUrl.hostname)) {
|
|
197
|
-
diagnostics.push({
|
|
198
|
-
code: "openclaw_callback_loopback",
|
|
199
|
-
level: "warn",
|
|
200
|
-
message: "OpenClaw callback endpoint uses loopback hostname.",
|
|
201
|
-
hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.",
|
|
424
|
+
message: `Gateway endpoint set to ${gatewayUrl.toString()}`
|
|
202
425
|
});
|
|
203
426
|
}
|
|
204
427
|
}
|
|
205
428
|
catch {
|
|
206
429
|
diagnostics.push({
|
|
207
|
-
code: "
|
|
430
|
+
code: "openclaw_gateway_url_invalid",
|
|
208
431
|
level: "warn",
|
|
209
|
-
message: `Invalid
|
|
432
|
+
message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}`
|
|
210
433
|
});
|
|
434
|
+
fatalErrors.push("agentDefaultsPayload.url is not a valid URL");
|
|
211
435
|
}
|
|
212
436
|
}
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
437
|
+
const headers = normalizeHeaderMap(defaults.headers) ?? {};
|
|
438
|
+
const gatewayToken = headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
|
439
|
+
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
|
440
|
+
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
|
|
441
|
+
if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) {
|
|
442
|
+
headers["x-openclaw-token"] = gatewayToken;
|
|
217
443
|
}
|
|
218
|
-
|
|
219
|
-
if (headers)
|
|
444
|
+
if (Object.keys(headers).length > 0) {
|
|
220
445
|
normalized.headers = headers;
|
|
221
|
-
|
|
222
|
-
|
|
446
|
+
}
|
|
447
|
+
if (!gatewayToken) {
|
|
448
|
+
diagnostics.push({
|
|
449
|
+
code: "openclaw_gateway_auth_header_missing",
|
|
450
|
+
level: "warn",
|
|
451
|
+
message: "Gateway auth token is missing from agent defaults.",
|
|
452
|
+
hint: "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)."
|
|
453
|
+
});
|
|
454
|
+
fatalErrors.push("agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required");
|
|
455
|
+
}
|
|
456
|
+
else if (gatewayToken.trim().length < 16) {
|
|
457
|
+
diagnostics.push({
|
|
458
|
+
code: "openclaw_gateway_auth_header_too_short",
|
|
459
|
+
level: "warn",
|
|
460
|
+
message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`,
|
|
461
|
+
hint: "Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)."
|
|
462
|
+
});
|
|
463
|
+
fatalErrors.push("agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token");
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
diagnostics.push({
|
|
467
|
+
code: "openclaw_gateway_auth_header_configured",
|
|
468
|
+
level: "info",
|
|
469
|
+
message: "Gateway auth token configured."
|
|
470
|
+
});
|
|
223
471
|
}
|
|
224
472
|
if (isPlainObject(defaults.payloadTemplate)) {
|
|
225
473
|
normalized.payloadTemplate = defaults.payloadTemplate;
|
|
226
474
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
475
|
+
const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth);
|
|
476
|
+
const disableDeviceAuth = parsedDisableDeviceAuth === true;
|
|
477
|
+
if (parsedDisableDeviceAuth !== null) {
|
|
478
|
+
normalized.disableDeviceAuth = parsedDisableDeviceAuth;
|
|
479
|
+
}
|
|
480
|
+
const configuredDevicePrivateKeyPem = nonEmptyTrimmedString(defaults.devicePrivateKeyPem);
|
|
481
|
+
if (configuredDevicePrivateKeyPem) {
|
|
482
|
+
normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem;
|
|
483
|
+
diagnostics.push({
|
|
484
|
+
code: "openclaw_gateway_device_key_configured",
|
|
485
|
+
level: "info",
|
|
486
|
+
message: "Gateway device key configured. Pairing approvals should persist for this agent."
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
else if (!disableDeviceAuth) {
|
|
490
|
+
try {
|
|
491
|
+
normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem();
|
|
492
|
+
diagnostics.push({
|
|
493
|
+
code: "openclaw_gateway_device_key_generated",
|
|
494
|
+
level: "info",
|
|
495
|
+
message: "Generated persistent gateway device key for this join. Pairing approvals should persist for this agent."
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
diagnostics.push({
|
|
500
|
+
code: "openclaw_gateway_device_key_generate_failed",
|
|
501
|
+
level: "warn",
|
|
502
|
+
message: `Failed to generate gateway device key: ${err instanceof Error ? err.message : String(err)}`,
|
|
503
|
+
hint: "Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true."
|
|
504
|
+
});
|
|
505
|
+
fatalErrors.push("Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true.");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const waitTimeoutMs = typeof defaults.waitTimeoutMs === "number" &&
|
|
509
|
+
Number.isFinite(defaults.waitTimeoutMs)
|
|
510
|
+
? Math.floor(defaults.waitTimeoutMs)
|
|
511
|
+
: typeof defaults.waitTimeoutMs === "string"
|
|
512
|
+
? Number.parseInt(defaults.waitTimeoutMs.trim(), 10)
|
|
513
|
+
: NaN;
|
|
514
|
+
if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) {
|
|
515
|
+
normalized.waitTimeoutMs = waitTimeoutMs;
|
|
516
|
+
}
|
|
517
|
+
const timeoutSec = typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)
|
|
518
|
+
? Math.floor(defaults.timeoutSec)
|
|
519
|
+
: typeof defaults.timeoutSec === "string"
|
|
520
|
+
? Number.parseInt(defaults.timeoutSec.trim(), 10)
|
|
521
|
+
: NaN;
|
|
522
|
+
if (Number.isFinite(timeoutSec) && timeoutSec > 0) {
|
|
523
|
+
normalized.timeoutSec = timeoutSec;
|
|
524
|
+
}
|
|
525
|
+
const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy);
|
|
526
|
+
if (sessionKeyStrategy === "fixed" ||
|
|
527
|
+
sessionKeyStrategy === "issue" ||
|
|
528
|
+
sessionKeyStrategy === "run") {
|
|
529
|
+
normalized.sessionKeyStrategy = sessionKeyStrategy;
|
|
530
|
+
}
|
|
531
|
+
const sessionKey = nonEmptyTrimmedString(defaults.sessionKey);
|
|
532
|
+
if (sessionKey) {
|
|
533
|
+
normalized.sessionKey = sessionKey;
|
|
534
|
+
}
|
|
535
|
+
const role = nonEmptyTrimmedString(defaults.role);
|
|
536
|
+
if (role) {
|
|
537
|
+
normalized.role = role;
|
|
538
|
+
}
|
|
539
|
+
if (Array.isArray(defaults.scopes)) {
|
|
540
|
+
const scopes = defaults.scopes
|
|
541
|
+
.filter((entry) => typeof entry === "string")
|
|
542
|
+
.map((entry) => entry.trim())
|
|
543
|
+
.filter(Boolean);
|
|
544
|
+
if (scopes.length > 0) {
|
|
545
|
+
normalized.scopes = scopes;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string"
|
|
549
|
+
? defaults.paperclipApiUrl.trim()
|
|
550
|
+
: "";
|
|
551
|
+
if (rawPaperclipApiUrl) {
|
|
552
|
+
try {
|
|
553
|
+
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
|
|
554
|
+
if (parsedPaperclipApiUrl.protocol !== "http:" &&
|
|
555
|
+
parsedPaperclipApiUrl.protocol !== "https:") {
|
|
556
|
+
diagnostics.push({
|
|
557
|
+
code: "openclaw_gateway_paperclip_api_url_protocol",
|
|
558
|
+
level: "warn",
|
|
559
|
+
message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).`
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString();
|
|
564
|
+
diagnostics.push({
|
|
565
|
+
code: "openclaw_gateway_paperclip_api_url_configured",
|
|
566
|
+
level: "info",
|
|
567
|
+
message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}`
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
diagnostics.push({
|
|
573
|
+
code: "openclaw_gateway_paperclip_api_url_invalid",
|
|
574
|
+
level: "warn",
|
|
575
|
+
message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { normalized, diagnostics, fatalErrors };
|
|
235
580
|
}
|
|
236
581
|
function toInviteSummaryResponse(req, token, invite) {
|
|
237
582
|
const baseUrl = requestBaseUrl(req);
|
|
238
583
|
const onboardingPath = `/api/invites/${token}/onboarding`;
|
|
584
|
+
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
|
585
|
+
const inviteMessage = extractInviteMessage(invite);
|
|
239
586
|
return {
|
|
240
587
|
id: invite.id,
|
|
241
588
|
companyId: invite.companyId,
|
|
@@ -244,57 +591,386 @@ function toInviteSummaryResponse(req, token, invite) {
|
|
|
244
591
|
expiresAt: invite.expiresAt,
|
|
245
592
|
onboardingPath,
|
|
246
593
|
onboardingUrl: baseUrl ? `${baseUrl}${onboardingPath}` : onboardingPath,
|
|
594
|
+
onboardingTextPath,
|
|
595
|
+
onboardingTextUrl: baseUrl
|
|
596
|
+
? `${baseUrl}${onboardingTextPath}`
|
|
597
|
+
: onboardingTextPath,
|
|
247
598
|
skillIndexPath: "/api/skills/index",
|
|
248
|
-
skillIndexUrl: baseUrl
|
|
599
|
+
skillIndexUrl: baseUrl
|
|
600
|
+
? `${baseUrl}/api/skills/index`
|
|
601
|
+
: "/api/skills/index",
|
|
602
|
+
inviteMessage
|
|
249
603
|
};
|
|
250
604
|
}
|
|
605
|
+
function buildOnboardingDiscoveryDiagnostics(input) {
|
|
606
|
+
const diagnostics = [];
|
|
607
|
+
let apiHost = null;
|
|
608
|
+
if (input.apiBaseUrl) {
|
|
609
|
+
try {
|
|
610
|
+
apiHost = normalizeHostname(new URL(input.apiBaseUrl).hostname);
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
apiHost = null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const bindHost = normalizeHostname(input.bindHost);
|
|
617
|
+
const allowSet = new Set(input.allowedHostnames
|
|
618
|
+
.map((entry) => normalizeHostname(entry))
|
|
619
|
+
.filter((entry) => Boolean(entry)));
|
|
620
|
+
if (apiHost && isLoopbackHost(apiHost)) {
|
|
621
|
+
diagnostics.push({
|
|
622
|
+
code: "openclaw_onboarding_api_loopback",
|
|
623
|
+
level: "warn",
|
|
624
|
+
message: "Onboarding URL resolves to loopback hostname. Remote OpenClaw agents cannot reach localhost on your Paperclip host.",
|
|
625
|
+
hint: "Use a reachable hostname/IP (for example Tailscale hostname, Docker host alias, or public domain)."
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (input.deploymentMode === "authenticated" &&
|
|
629
|
+
input.deploymentExposure === "private" &&
|
|
630
|
+
(!bindHost || isLoopbackHost(bindHost))) {
|
|
631
|
+
diagnostics.push({
|
|
632
|
+
code: "openclaw_onboarding_private_loopback_bind",
|
|
633
|
+
level: "warn",
|
|
634
|
+
message: "Paperclip is bound to loopback in authenticated/private mode.",
|
|
635
|
+
hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding."
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (input.deploymentMode === "authenticated" &&
|
|
639
|
+
input.deploymentExposure === "private" &&
|
|
640
|
+
apiHost &&
|
|
641
|
+
!isLoopbackHost(apiHost) &&
|
|
642
|
+
allowSet.size > 0 &&
|
|
643
|
+
!allowSet.has(apiHost)) {
|
|
644
|
+
diagnostics.push({
|
|
645
|
+
code: "openclaw_onboarding_private_host_not_allowed",
|
|
646
|
+
level: "warn",
|
|
647
|
+
message: `Onboarding host "${apiHost}" is not in allowed hostnames for authenticated/private mode.`,
|
|
648
|
+
hint: `Run pnpm paperclipai allowed-hostname ${apiHost}`
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return diagnostics;
|
|
652
|
+
}
|
|
653
|
+
function buildOnboardingConnectionCandidates(input) {
|
|
654
|
+
let base = null;
|
|
655
|
+
try {
|
|
656
|
+
if (input.apiBaseUrl) {
|
|
657
|
+
base = new URL(input.apiBaseUrl);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
base = null;
|
|
662
|
+
}
|
|
663
|
+
const protocol = base?.protocol ?? "http:";
|
|
664
|
+
const port = base?.port ? `:${base.port}` : "";
|
|
665
|
+
const candidates = new Set();
|
|
666
|
+
if (base) {
|
|
667
|
+
candidates.add(base.origin);
|
|
668
|
+
}
|
|
669
|
+
const bindHost = normalizeHostname(input.bindHost);
|
|
670
|
+
if (bindHost && !isLoopbackHost(bindHost)) {
|
|
671
|
+
candidates.add(`${protocol}//${bindHost}${port}`);
|
|
672
|
+
}
|
|
673
|
+
for (const rawHost of input.allowedHostnames) {
|
|
674
|
+
const host = normalizeHostname(rawHost);
|
|
675
|
+
if (!host)
|
|
676
|
+
continue;
|
|
677
|
+
candidates.add(`${protocol}//${host}${port}`);
|
|
678
|
+
}
|
|
679
|
+
if (base && isLoopbackHost(base.hostname)) {
|
|
680
|
+
candidates.add(`${protocol}//host.docker.internal${port}`);
|
|
681
|
+
}
|
|
682
|
+
return Array.from(candidates);
|
|
683
|
+
}
|
|
251
684
|
function buildInviteOnboardingManifest(req, token, invite, opts) {
|
|
252
685
|
const baseUrl = requestBaseUrl(req);
|
|
253
686
|
const skillPath = "/api/skills/paperclip";
|
|
254
687
|
const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath;
|
|
255
688
|
const registrationEndpointPath = `/api/invites/${token}/accept`;
|
|
256
|
-
const registrationEndpointUrl = baseUrl
|
|
689
|
+
const registrationEndpointUrl = baseUrl
|
|
690
|
+
? `${baseUrl}${registrationEndpointPath}`
|
|
691
|
+
: registrationEndpointPath;
|
|
692
|
+
const onboardingTextPath = `/api/invites/${token}/onboarding.txt`;
|
|
693
|
+
const onboardingTextUrl = baseUrl
|
|
694
|
+
? `${baseUrl}${onboardingTextPath}`
|
|
695
|
+
: onboardingTextPath;
|
|
696
|
+
const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({
|
|
697
|
+
apiBaseUrl: baseUrl,
|
|
698
|
+
deploymentMode: opts.deploymentMode,
|
|
699
|
+
deploymentExposure: opts.deploymentExposure,
|
|
700
|
+
bindHost: opts.bindHost,
|
|
701
|
+
allowedHostnames: opts.allowedHostnames
|
|
702
|
+
});
|
|
703
|
+
const connectionCandidates = buildOnboardingConnectionCandidates({
|
|
704
|
+
apiBaseUrl: baseUrl,
|
|
705
|
+
bindHost: opts.bindHost,
|
|
706
|
+
allowedHostnames: opts.allowedHostnames
|
|
707
|
+
});
|
|
257
708
|
return {
|
|
258
709
|
invite: toInviteSummaryResponse(req, token, invite),
|
|
259
710
|
onboarding: {
|
|
260
|
-
instructions: "Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and
|
|
261
|
-
|
|
711
|
+
instructions: "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
|
|
712
|
+
inviteMessage: extractInviteMessage(invite),
|
|
713
|
+
recommendedAdapterType: "openclaw_gateway",
|
|
262
714
|
requiredFields: {
|
|
263
715
|
requestType: "agent",
|
|
264
716
|
agentName: "Display name for this agent",
|
|
265
|
-
adapterType: "Use '
|
|
717
|
+
adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents",
|
|
266
718
|
capabilities: "Optional capability summary",
|
|
267
|
-
agentDefaultsPayload: "
|
|
719
|
+
agentDefaultsPayload: "Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem."
|
|
268
720
|
},
|
|
269
721
|
registrationEndpoint: {
|
|
270
722
|
method: "POST",
|
|
271
723
|
path: registrationEndpointPath,
|
|
272
|
-
url: registrationEndpointUrl
|
|
724
|
+
url: registrationEndpointUrl
|
|
273
725
|
},
|
|
274
726
|
claimEndpointTemplate: {
|
|
275
727
|
method: "POST",
|
|
276
728
|
path: "/api/join-requests/{requestId}/claim-api-key",
|
|
277
729
|
body: {
|
|
278
|
-
claimSecret: "one-time claim secret returned when the join request is created"
|
|
279
|
-
}
|
|
730
|
+
claimSecret: "one-time claim secret returned when the join request is created"
|
|
731
|
+
}
|
|
280
732
|
},
|
|
281
733
|
connectivity: {
|
|
282
734
|
deploymentMode: opts.deploymentMode,
|
|
283
735
|
deploymentExposure: opts.deploymentExposure,
|
|
284
736
|
bindHost: opts.bindHost,
|
|
285
737
|
allowedHostnames: opts.allowedHostnames,
|
|
286
|
-
|
|
738
|
+
connectionCandidates,
|
|
739
|
+
diagnostics: discoveryDiagnostics,
|
|
740
|
+
guidance: opts.deploymentMode === "authenticated" &&
|
|
741
|
+
opts.deploymentExposure === "private"
|
|
287
742
|
? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname <host>`."
|
|
288
|
-
: "Ensure OpenClaw can reach this Paperclip API base URL for
|
|
743
|
+
: "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls."
|
|
744
|
+
},
|
|
745
|
+
textInstructions: {
|
|
746
|
+
path: onboardingTextPath,
|
|
747
|
+
url: onboardingTextUrl,
|
|
748
|
+
contentType: "text/plain"
|
|
289
749
|
},
|
|
290
750
|
skill: {
|
|
291
751
|
name: "paperclip",
|
|
292
752
|
path: skillPath,
|
|
293
753
|
url: skillUrl,
|
|
294
|
-
installPath: "~/.openclaw/skills/paperclip/SKILL.md"
|
|
295
|
-
}
|
|
296
|
-
}
|
|
754
|
+
installPath: "~/.openclaw/skills/paperclip/SKILL.md"
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
export function buildInviteOnboardingTextDocument(req, token, invite, opts) {
|
|
760
|
+
const manifest = buildInviteOnboardingManifest(req, token, invite, opts);
|
|
761
|
+
const onboarding = manifest.onboarding;
|
|
762
|
+
const diagnostics = Array.isArray(onboarding.connectivity?.diagnostics)
|
|
763
|
+
? onboarding.connectivity.diagnostics
|
|
764
|
+
: [];
|
|
765
|
+
const lines = [];
|
|
766
|
+
const appendBlock = (block) => {
|
|
767
|
+
const trimmed = block.replace(/^\n/, "").replace(/\n\s*$/, "");
|
|
768
|
+
const lineIndentation = trimmed
|
|
769
|
+
.split("\n")
|
|
770
|
+
.filter((line) => line.trim().length > 0)
|
|
771
|
+
.map((line) => line.match(/^(\s*)/)?.[0].length ?? 0);
|
|
772
|
+
const minIndent = lineIndentation.length > 0 ? Math.min(...lineIndentation) : 0;
|
|
773
|
+
for (const line of trimmed.split("\n")) {
|
|
774
|
+
lines.push(line.slice(minIndent));
|
|
775
|
+
}
|
|
297
776
|
};
|
|
777
|
+
appendBlock(`
|
|
778
|
+
# Paperclip OpenClaw Gateway Onboarding
|
|
779
|
+
|
|
780
|
+
This document is meant to be readable by both humans and agents.
|
|
781
|
+
|
|
782
|
+
## Invite
|
|
783
|
+
- inviteType: ${invite.inviteType}
|
|
784
|
+
- allowedJoinTypes: ${invite.allowedJoinTypes}
|
|
785
|
+
- expiresAt: ${invite.expiresAt.toISOString()}
|
|
786
|
+
`);
|
|
787
|
+
if (onboarding.inviteMessage) {
|
|
788
|
+
appendBlock(`
|
|
789
|
+
## Message from inviter
|
|
790
|
+
${onboarding.inviteMessage}
|
|
791
|
+
`);
|
|
792
|
+
}
|
|
793
|
+
appendBlock(`
|
|
794
|
+
## Step 0
|
|
795
|
+
|
|
796
|
+
Get the OpenClaw gateway auth token (THIS MUST BE SENT)
|
|
797
|
+
Token lives in:
|
|
798
|
+
|
|
799
|
+
~/.openclaw/openclaw.json -> gateway.auth.token
|
|
800
|
+
Extract:
|
|
801
|
+
|
|
802
|
+
TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')"
|
|
803
|
+
test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1)
|
|
804
|
+
test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1)
|
|
805
|
+
|
|
806
|
+
3) IMPORTANT: Don't accidentally drop the token when generating JSON
|
|
807
|
+
If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var.
|
|
808
|
+
|
|
809
|
+
Safe payload build looks sort of like this (substitute where necessary):
|
|
810
|
+
|
|
811
|
+
BODY="$(node -e '
|
|
812
|
+
const token = process.argv[1];
|
|
813
|
+
if (!token) process.exit(2);
|
|
814
|
+
const body = {
|
|
815
|
+
requestType: "agent",
|
|
816
|
+
agentName: "OpenClaw",
|
|
817
|
+
adapterType: "openclaw_gateway",
|
|
818
|
+
capabilities: "OpenClaw agent adapter",
|
|
819
|
+
agentDefaultsPayload: {
|
|
820
|
+
url: "ws://127.0.0.1:18789",
|
|
821
|
+
paperclipApiUrl: "http://host.docker.internal:3100",
|
|
822
|
+
headers: { "x-openclaw-token": token },
|
|
823
|
+
waitTimeoutMs: 120000,
|
|
824
|
+
sessionKeyStrategy: "issue",
|
|
825
|
+
role: "operator",
|
|
826
|
+
scopes: ["operator.admin"]
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
process.stdout.write(JSON.stringify(body));
|
|
830
|
+
' "$TOKEN")"
|
|
831
|
+
|
|
832
|
+
## Step 1: Submit agent join request
|
|
833
|
+
${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}
|
|
834
|
+
|
|
835
|
+
IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token.
|
|
836
|
+
Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred.
|
|
837
|
+
Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL.
|
|
838
|
+
Pairing mode requirement:
|
|
839
|
+
- Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable.
|
|
840
|
+
- You may set disableDeviceAuth=true only for special environments that cannot support pairing.
|
|
841
|
+
- First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry.
|
|
842
|
+
Do NOT use /v1/responses or /hooks/* in this gateway join flow.
|
|
843
|
+
|
|
844
|
+
Body (JSON):
|
|
845
|
+
{
|
|
846
|
+
"requestType": "agent",
|
|
847
|
+
"agentName": "My OpenClaw Agent",
|
|
848
|
+
"adapterType": "openclaw_gateway",
|
|
849
|
+
"capabilities": "Optional summary",
|
|
850
|
+
"agentDefaultsPayload": {
|
|
851
|
+
"url": "wss://your-openclaw-gateway.example",
|
|
852
|
+
"paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",
|
|
853
|
+
"headers": { "x-openclaw-token": "replace-me" },
|
|
854
|
+
"waitTimeoutMs": 120000,
|
|
855
|
+
"sessionKeyStrategy": "issue",
|
|
856
|
+
"role": "operator",
|
|
857
|
+
"scopes": ["operator.admin"]
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
Expected response includes:
|
|
862
|
+
- request id
|
|
863
|
+
- one-time claimSecret
|
|
864
|
+
- claimApiKeyPath
|
|
865
|
+
|
|
866
|
+
## Step 2: Wait for board approval
|
|
867
|
+
The board approves the join request in Paperclip before key claim is allowed.
|
|
868
|
+
|
|
869
|
+
## Step 3: Claim API key (one-time)
|
|
870
|
+
${onboarding.claimEndpointTemplate.method} /api/join-requests/{requestId}/claim-api-key
|
|
871
|
+
|
|
872
|
+
Body (JSON):
|
|
873
|
+
{
|
|
874
|
+
"claimSecret": "<one-time-claim-secret>"
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
On successful claim, save the full JSON response to:
|
|
878
|
+
|
|
879
|
+
- ~/.openclaw/workspace/paperclip-claimed-api-key.json
|
|
880
|
+
chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json
|
|
881
|
+
|
|
882
|
+
And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here:
|
|
883
|
+
https://docs.openclaw.ai/help/environment
|
|
884
|
+
|
|
885
|
+
e.g.
|
|
886
|
+
|
|
887
|
+
{
|
|
888
|
+
env: {
|
|
889
|
+
PAPERCLIP_API_KEY: "...",
|
|
890
|
+
PAPERCLIP_API_URL: "...",
|
|
891
|
+
},
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL from the saved token field for every heartbeat run.
|
|
895
|
+
|
|
896
|
+
Important:
|
|
897
|
+
- claim secrets expire
|
|
898
|
+
- claim secrets are single-use
|
|
899
|
+
- claim fails before board approval
|
|
900
|
+
|
|
901
|
+
## Step 4: Install Paperclip skill in OpenClaw
|
|
902
|
+
GET ${onboarding.skill.url}
|
|
903
|
+
Install path: ${onboarding.skill.installPath}
|
|
904
|
+
|
|
905
|
+
Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL
|
|
906
|
+
|
|
907
|
+
## Text onboarding URL
|
|
908
|
+
${onboarding.textInstructions.url}
|
|
909
|
+
|
|
910
|
+
## Connectivity guidance
|
|
911
|
+
${onboarding.connectivity?.guidance ??
|
|
912
|
+
"Ensure Paperclip is reachable from your OpenClaw runtime."}
|
|
913
|
+
`);
|
|
914
|
+
const connectionCandidates = Array.isArray(onboarding.connectivity?.connectionCandidates)
|
|
915
|
+
? onboarding.connectivity.connectionCandidates.filter((entry) => Boolean(entry))
|
|
916
|
+
: [];
|
|
917
|
+
if (connectionCandidates.length > 0) {
|
|
918
|
+
lines.push("## Suggested Paperclip base URLs to try");
|
|
919
|
+
for (const candidate of connectionCandidates) {
|
|
920
|
+
lines.push(`- ${candidate}`);
|
|
921
|
+
}
|
|
922
|
+
appendBlock(`
|
|
923
|
+
|
|
924
|
+
Test each candidate with:
|
|
925
|
+
- GET <candidate>/api/health
|
|
926
|
+
- set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl when submitting your join request
|
|
927
|
+
|
|
928
|
+
If none are reachable: ask your human operator for a reachable hostname/address and help them update network configuration.
|
|
929
|
+
For authenticated/private mode, they may need:
|
|
930
|
+
- pnpm paperclipai allowed-hostname <host>
|
|
931
|
+
- then restart Paperclip and retry onboarding.
|
|
932
|
+
`);
|
|
933
|
+
}
|
|
934
|
+
if (diagnostics.length > 0) {
|
|
935
|
+
lines.push("## Connectivity diagnostics");
|
|
936
|
+
for (const diag of diagnostics) {
|
|
937
|
+
lines.push(`- [${diag.level}] ${diag.message}`);
|
|
938
|
+
if (diag.hint)
|
|
939
|
+
lines.push(` hint: ${diag.hint}`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
appendBlock(`
|
|
943
|
+
|
|
944
|
+
## Helpful endpoints
|
|
945
|
+
${onboarding.registrationEndpoint.path}
|
|
946
|
+
${onboarding.claimEndpointTemplate.path}
|
|
947
|
+
${onboarding.skill.path}
|
|
948
|
+
${manifest.invite.onboardingPath}
|
|
949
|
+
`);
|
|
950
|
+
return `${lines.join("\n")}\n`;
|
|
951
|
+
}
|
|
952
|
+
function extractInviteMessage(invite) {
|
|
953
|
+
const rawDefaults = invite.defaultsPayload;
|
|
954
|
+
if (!rawDefaults ||
|
|
955
|
+
typeof rawDefaults !== "object" ||
|
|
956
|
+
Array.isArray(rawDefaults)) {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
const rawMessage = rawDefaults.agentMessage;
|
|
960
|
+
if (typeof rawMessage !== "string") {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
const trimmed = rawMessage.trim();
|
|
964
|
+
return trimmed.length ? trimmed : null;
|
|
965
|
+
}
|
|
966
|
+
function mergeInviteDefaults(defaultsPayload, agentMessage) {
|
|
967
|
+
const merged = defaultsPayload && typeof defaultsPayload === "object"
|
|
968
|
+
? { ...defaultsPayload }
|
|
969
|
+
: {};
|
|
970
|
+
if (agentMessage) {
|
|
971
|
+
merged.agentMessage = agentMessage;
|
|
972
|
+
}
|
|
973
|
+
return Object.keys(merged).length ? merged : null;
|
|
298
974
|
}
|
|
299
975
|
function requestIp(req) {
|
|
300
976
|
const forwarded = req.header("x-forwarded-for");
|
|
@@ -345,13 +1021,111 @@ function grantsFromDefaults(defaultsPayload, key) {
|
|
|
345
1021
|
continue;
|
|
346
1022
|
result.push({
|
|
347
1023
|
permissionKey: record.permissionKey,
|
|
348
|
-
scope: record.scope &&
|
|
1024
|
+
scope: record.scope &&
|
|
1025
|
+
typeof record.scope === "object" &&
|
|
1026
|
+
!Array.isArray(record.scope)
|
|
349
1027
|
? record.scope
|
|
350
|
-
: null
|
|
1028
|
+
: null
|
|
351
1029
|
});
|
|
352
1030
|
}
|
|
353
1031
|
return result;
|
|
354
1032
|
}
|
|
1033
|
+
export function resolveJoinRequestAgentManagerId(candidates) {
|
|
1034
|
+
const ceoCandidates = candidates.filter((candidate) => candidate.role === "ceo");
|
|
1035
|
+
if (ceoCandidates.length === 0)
|
|
1036
|
+
return null;
|
|
1037
|
+
const rootCeo = ceoCandidates.find((candidate) => candidate.reportsTo === null);
|
|
1038
|
+
return (rootCeo ?? ceoCandidates[0] ?? null)?.id ?? null;
|
|
1039
|
+
}
|
|
1040
|
+
function isInviteTokenHashCollisionError(error) {
|
|
1041
|
+
const candidates = [
|
|
1042
|
+
error,
|
|
1043
|
+
error?.cause ?? null
|
|
1044
|
+
];
|
|
1045
|
+
for (const candidate of candidates) {
|
|
1046
|
+
if (!candidate || typeof candidate !== "object")
|
|
1047
|
+
continue;
|
|
1048
|
+
const code = "code" in candidate && typeof candidate.code === "string"
|
|
1049
|
+
? candidate.code
|
|
1050
|
+
: null;
|
|
1051
|
+
const message = "message" in candidate && typeof candidate.message === "string"
|
|
1052
|
+
? candidate.message
|
|
1053
|
+
: "";
|
|
1054
|
+
const constraint = "constraint" in candidate && typeof candidate.constraint === "string"
|
|
1055
|
+
? candidate.constraint
|
|
1056
|
+
: null;
|
|
1057
|
+
if (code !== "23505")
|
|
1058
|
+
continue;
|
|
1059
|
+
if (constraint === "invites_token_hash_unique_idx")
|
|
1060
|
+
return true;
|
|
1061
|
+
if (message.includes("invites_token_hash_unique_idx"))
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1066
|
+
function isAbortError(error) {
|
|
1067
|
+
return error instanceof Error && error.name === "AbortError";
|
|
1068
|
+
}
|
|
1069
|
+
async function probeInviteResolutionTarget(url, timeoutMs) {
|
|
1070
|
+
const startedAt = Date.now();
|
|
1071
|
+
const controller = new AbortController();
|
|
1072
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1073
|
+
try {
|
|
1074
|
+
const response = await fetch(url, {
|
|
1075
|
+
method: "HEAD",
|
|
1076
|
+
redirect: "manual",
|
|
1077
|
+
signal: controller.signal
|
|
1078
|
+
});
|
|
1079
|
+
const durationMs = Date.now() - startedAt;
|
|
1080
|
+
if (response.ok ||
|
|
1081
|
+
response.status === 401 ||
|
|
1082
|
+
response.status === 403 ||
|
|
1083
|
+
response.status === 404 ||
|
|
1084
|
+
response.status === 405 ||
|
|
1085
|
+
response.status === 422 ||
|
|
1086
|
+
response.status === 500 ||
|
|
1087
|
+
response.status === 501) {
|
|
1088
|
+
return {
|
|
1089
|
+
status: "reachable",
|
|
1090
|
+
method: "HEAD",
|
|
1091
|
+
durationMs,
|
|
1092
|
+
httpStatus: response.status,
|
|
1093
|
+
message: `Webhook endpoint responded to HEAD with HTTP ${response.status}.`
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
return {
|
|
1097
|
+
status: "unreachable",
|
|
1098
|
+
method: "HEAD",
|
|
1099
|
+
durationMs,
|
|
1100
|
+
httpStatus: response.status,
|
|
1101
|
+
message: `Webhook endpoint probe returned HTTP ${response.status}.`
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
catch (error) {
|
|
1105
|
+
const durationMs = Date.now() - startedAt;
|
|
1106
|
+
if (isAbortError(error)) {
|
|
1107
|
+
return {
|
|
1108
|
+
status: "timeout",
|
|
1109
|
+
method: "HEAD",
|
|
1110
|
+
durationMs,
|
|
1111
|
+
httpStatus: null,
|
|
1112
|
+
message: `Webhook endpoint probe timed out after ${timeoutMs}ms.`
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
return {
|
|
1116
|
+
status: "unreachable",
|
|
1117
|
+
method: "HEAD",
|
|
1118
|
+
durationMs,
|
|
1119
|
+
httpStatus: null,
|
|
1120
|
+
message: error instanceof Error
|
|
1121
|
+
? error.message
|
|
1122
|
+
: "Webhook endpoint probe failed."
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
finally {
|
|
1126
|
+
clearTimeout(timeout);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
355
1129
|
export function accessRoutes(db, opts) {
|
|
356
1130
|
const router = Router();
|
|
357
1131
|
const access = accessService(db);
|
|
@@ -382,20 +1156,25 @@ export function accessRoutes(db, opts) {
|
|
|
382
1156
|
throw notFound("Board claim challenge not found");
|
|
383
1157
|
if (!code)
|
|
384
1158
|
throw badRequest("Claim code is required");
|
|
385
|
-
if (req.actor.type !== "board" ||
|
|
1159
|
+
if (req.actor.type !== "board" ||
|
|
1160
|
+
req.actor.source !== "session" ||
|
|
1161
|
+
!req.actor.userId) {
|
|
386
1162
|
throw unauthorized("Sign in before claiming board ownership");
|
|
387
1163
|
}
|
|
388
1164
|
const claimed = await claimBoardOwnership(db, {
|
|
389
1165
|
token,
|
|
390
1166
|
code,
|
|
391
|
-
userId: req.actor.userId
|
|
1167
|
+
userId: req.actor.userId
|
|
392
1168
|
});
|
|
393
1169
|
if (claimed.status === "invalid")
|
|
394
1170
|
throw notFound("Board claim challenge not found");
|
|
395
1171
|
if (claimed.status === "expired")
|
|
396
1172
|
throw conflict("Board claim challenge expired. Restart server to generate a new one.");
|
|
397
1173
|
if (claimed.status === "claimed") {
|
|
398
|
-
res.json({
|
|
1174
|
+
res.json({
|
|
1175
|
+
claimed: true,
|
|
1176
|
+
userId: claimed.claimedByUserId ?? req.actor.userId
|
|
1177
|
+
});
|
|
399
1178
|
return;
|
|
400
1179
|
}
|
|
401
1180
|
throw conflict("Board claim challenge is no longer available");
|
|
@@ -418,12 +1197,77 @@ export function accessRoutes(db, opts) {
|
|
|
418
1197
|
if (!allowed)
|
|
419
1198
|
throw forbidden("Permission denied");
|
|
420
1199
|
}
|
|
1200
|
+
async function assertCanGenerateOpenClawInvitePrompt(req, companyId) {
|
|
1201
|
+
assertCompanyAccess(req, companyId);
|
|
1202
|
+
if (req.actor.type === "agent") {
|
|
1203
|
+
if (!req.actor.agentId)
|
|
1204
|
+
throw forbidden("Agent authentication required");
|
|
1205
|
+
const actorAgent = await agents.getById(req.actor.agentId);
|
|
1206
|
+
if (!actorAgent || actorAgent.companyId !== companyId) {
|
|
1207
|
+
throw forbidden("Agent key cannot access another company");
|
|
1208
|
+
}
|
|
1209
|
+
if (actorAgent.role !== "ceo") {
|
|
1210
|
+
throw forbidden("Only CEO agents can generate OpenClaw invite prompts");
|
|
1211
|
+
}
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
if (req.actor.type !== "board")
|
|
1215
|
+
throw unauthorized();
|
|
1216
|
+
if (isLocalImplicit(req))
|
|
1217
|
+
return;
|
|
1218
|
+
const allowed = await access.canUser(companyId, req.actor.userId, "users:invite");
|
|
1219
|
+
if (!allowed)
|
|
1220
|
+
throw forbidden("Permission denied");
|
|
1221
|
+
}
|
|
1222
|
+
async function createCompanyInviteForCompany(input) {
|
|
1223
|
+
const normalizedAgentMessage = typeof input.agentMessage === "string"
|
|
1224
|
+
? input.agentMessage.trim() || null
|
|
1225
|
+
: null;
|
|
1226
|
+
const insertValues = {
|
|
1227
|
+
companyId: input.companyId,
|
|
1228
|
+
inviteType: "company_join",
|
|
1229
|
+
allowedJoinTypes: input.allowedJoinTypes,
|
|
1230
|
+
defaultsPayload: mergeInviteDefaults(input.defaultsPayload ?? null, normalizedAgentMessage),
|
|
1231
|
+
expiresAt: companyInviteExpiresAt(),
|
|
1232
|
+
invitedByUserId: input.req.actor.userId ?? null
|
|
1233
|
+
};
|
|
1234
|
+
let token = null;
|
|
1235
|
+
let created = null;
|
|
1236
|
+
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
|
|
1237
|
+
const candidateToken = createInviteToken();
|
|
1238
|
+
try {
|
|
1239
|
+
const row = await db
|
|
1240
|
+
.insert(invites)
|
|
1241
|
+
.values({
|
|
1242
|
+
...insertValues,
|
|
1243
|
+
tokenHash: hashToken(candidateToken)
|
|
1244
|
+
})
|
|
1245
|
+
.returning()
|
|
1246
|
+
.then((rows) => rows[0]);
|
|
1247
|
+
token = candidateToken;
|
|
1248
|
+
created = row;
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
if (!isInviteTokenHashCollisionError(error)) {
|
|
1253
|
+
throw error;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (!token || !created) {
|
|
1258
|
+
throw conflict("Failed to generate a unique invite token. Please retry.");
|
|
1259
|
+
}
|
|
1260
|
+
return { token, created, normalizedAgentMessage };
|
|
1261
|
+
}
|
|
421
1262
|
router.get("/skills/index", (_req, res) => {
|
|
422
1263
|
res.json({
|
|
423
1264
|
skills: [
|
|
424
1265
|
{ name: "paperclip", path: "/api/skills/paperclip" },
|
|
425
|
-
{
|
|
426
|
-
|
|
1266
|
+
{
|
|
1267
|
+
name: "paperclip-create-agent",
|
|
1268
|
+
path: "/api/skills/paperclip-create-agent"
|
|
1269
|
+
}
|
|
1270
|
+
]
|
|
427
1271
|
});
|
|
428
1272
|
});
|
|
429
1273
|
router.get("/skills/:skillName", (req, res) => {
|
|
@@ -436,24 +1280,19 @@ export function accessRoutes(db, opts) {
|
|
|
436
1280
|
router.post("/companies/:companyId/invites", validate(createCompanyInviteSchema), async (req, res) => {
|
|
437
1281
|
const companyId = req.params.companyId;
|
|
438
1282
|
await assertCompanyPermission(req, companyId, "users:invite");
|
|
439
|
-
const token =
|
|
440
|
-
|
|
441
|
-
.insert(invites)
|
|
442
|
-
.values({
|
|
1283
|
+
const { token, created, normalizedAgentMessage } = await createCompanyInviteForCompany({
|
|
1284
|
+
req,
|
|
443
1285
|
companyId,
|
|
444
|
-
inviteType: "company_join",
|
|
445
|
-
tokenHash: hashToken(token),
|
|
446
1286
|
allowedJoinTypes: req.body.allowedJoinTypes,
|
|
447
1287
|
defaultsPayload: req.body.defaultsPayload ?? null,
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
})
|
|
451
|
-
.returning()
|
|
452
|
-
.then((rows) => rows[0]);
|
|
1288
|
+
agentMessage: req.body.agentMessage ?? null
|
|
1289
|
+
});
|
|
453
1290
|
await logActivity(db, {
|
|
454
1291
|
companyId,
|
|
455
1292
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
456
|
-
actorId: req.actor.type === "agent"
|
|
1293
|
+
actorId: req.actor.type === "agent"
|
|
1294
|
+
? req.actor.agentId ?? "unknown-agent"
|
|
1295
|
+
: req.actor.userId ?? "board",
|
|
457
1296
|
action: "invite.created",
|
|
458
1297
|
entityType: "invite",
|
|
459
1298
|
entityId: created.id,
|
|
@@ -461,12 +1300,53 @@ export function accessRoutes(db, opts) {
|
|
|
461
1300
|
inviteType: created.inviteType,
|
|
462
1301
|
allowedJoinTypes: created.allowedJoinTypes,
|
|
463
1302
|
expiresAt: created.expiresAt.toISOString(),
|
|
464
|
-
|
|
1303
|
+
hasAgentMessage: Boolean(normalizedAgentMessage)
|
|
1304
|
+
}
|
|
465
1305
|
});
|
|
1306
|
+
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
|
466
1307
|
res.status(201).json({
|
|
467
1308
|
...created,
|
|
468
1309
|
token,
|
|
469
1310
|
inviteUrl: `/invite/${token}`,
|
|
1311
|
+
onboardingTextPath: inviteSummary.onboardingTextPath,
|
|
1312
|
+
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
|
1313
|
+
inviteMessage: inviteSummary.inviteMessage
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
router.post("/companies/:companyId/openclaw/invite-prompt", validate(createOpenClawInvitePromptSchema), async (req, res) => {
|
|
1317
|
+
const companyId = req.params.companyId;
|
|
1318
|
+
await assertCanGenerateOpenClawInvitePrompt(req, companyId);
|
|
1319
|
+
const { token, created, normalizedAgentMessage } = await createCompanyInviteForCompany({
|
|
1320
|
+
req,
|
|
1321
|
+
companyId,
|
|
1322
|
+
allowedJoinTypes: "agent",
|
|
1323
|
+
defaultsPayload: null,
|
|
1324
|
+
agentMessage: req.body.agentMessage ?? null
|
|
1325
|
+
});
|
|
1326
|
+
await logActivity(db, {
|
|
1327
|
+
companyId,
|
|
1328
|
+
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
1329
|
+
actorId: req.actor.type === "agent"
|
|
1330
|
+
? req.actor.agentId ?? "unknown-agent"
|
|
1331
|
+
: req.actor.userId ?? "board",
|
|
1332
|
+
action: "invite.openclaw_prompt_created",
|
|
1333
|
+
entityType: "invite",
|
|
1334
|
+
entityId: created.id,
|
|
1335
|
+
details: {
|
|
1336
|
+
inviteType: created.inviteType,
|
|
1337
|
+
allowedJoinTypes: created.allowedJoinTypes,
|
|
1338
|
+
expiresAt: created.expiresAt.toISOString(),
|
|
1339
|
+
hasAgentMessage: Boolean(normalizedAgentMessage)
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
|
1343
|
+
res.status(201).json({
|
|
1344
|
+
...created,
|
|
1345
|
+
token,
|
|
1346
|
+
inviteUrl: `/invite/${token}`,
|
|
1347
|
+
onboardingTextPath: inviteSummary.onboardingTextPath,
|
|
1348
|
+
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
|
1349
|
+
inviteMessage: inviteSummary.inviteMessage
|
|
470
1350
|
});
|
|
471
1351
|
});
|
|
472
1352
|
router.get("/invites/:token", async (req, res) => {
|
|
@@ -478,7 +1358,10 @@ export function accessRoutes(db, opts) {
|
|
|
478
1358
|
.from(invites)
|
|
479
1359
|
.where(eq(invites.tokenHash, hashToken(token)))
|
|
480
1360
|
.then((rows) => rows[0] ?? null);
|
|
481
|
-
if (!invite ||
|
|
1361
|
+
if (!invite ||
|
|
1362
|
+
invite.revokedAt ||
|
|
1363
|
+
invite.acceptedAt ||
|
|
1364
|
+
inviteExpired(invite)) {
|
|
482
1365
|
throw notFound("Invite not found");
|
|
483
1366
|
}
|
|
484
1367
|
res.json(toInviteSummaryResponse(req, token, invite));
|
|
@@ -497,6 +1380,62 @@ export function accessRoutes(db, opts) {
|
|
|
497
1380
|
}
|
|
498
1381
|
res.json(buildInviteOnboardingManifest(req, token, invite, opts));
|
|
499
1382
|
});
|
|
1383
|
+
router.get("/invites/:token/onboarding.txt", async (req, res) => {
|
|
1384
|
+
const token = req.params.token.trim();
|
|
1385
|
+
if (!token)
|
|
1386
|
+
throw notFound("Invite not found");
|
|
1387
|
+
const invite = await db
|
|
1388
|
+
.select()
|
|
1389
|
+
.from(invites)
|
|
1390
|
+
.where(eq(invites.tokenHash, hashToken(token)))
|
|
1391
|
+
.then((rows) => rows[0] ?? null);
|
|
1392
|
+
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
|
1393
|
+
throw notFound("Invite not found");
|
|
1394
|
+
}
|
|
1395
|
+
res
|
|
1396
|
+
.type("text/plain; charset=utf-8")
|
|
1397
|
+
.send(buildInviteOnboardingTextDocument(req, token, invite, opts));
|
|
1398
|
+
});
|
|
1399
|
+
router.get("/invites/:token/test-resolution", async (req, res) => {
|
|
1400
|
+
const token = req.params.token.trim();
|
|
1401
|
+
if (!token)
|
|
1402
|
+
throw notFound("Invite not found");
|
|
1403
|
+
const invite = await db
|
|
1404
|
+
.select()
|
|
1405
|
+
.from(invites)
|
|
1406
|
+
.where(eq(invites.tokenHash, hashToken(token)))
|
|
1407
|
+
.then((rows) => rows[0] ?? null);
|
|
1408
|
+
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
|
1409
|
+
throw notFound("Invite not found");
|
|
1410
|
+
}
|
|
1411
|
+
const rawUrl = typeof req.query.url === "string" ? req.query.url.trim() : "";
|
|
1412
|
+
if (!rawUrl)
|
|
1413
|
+
throw badRequest("url query parameter is required");
|
|
1414
|
+
let target;
|
|
1415
|
+
try {
|
|
1416
|
+
target = new URL(rawUrl);
|
|
1417
|
+
}
|
|
1418
|
+
catch {
|
|
1419
|
+
throw badRequest("url must be an absolute http(s) URL");
|
|
1420
|
+
}
|
|
1421
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
1422
|
+
throw badRequest("url must use http or https");
|
|
1423
|
+
}
|
|
1424
|
+
const parsedTimeoutMs = typeof req.query.timeoutMs === "string"
|
|
1425
|
+
? Number(req.query.timeoutMs)
|
|
1426
|
+
: NaN;
|
|
1427
|
+
const timeoutMs = Number.isFinite(parsedTimeoutMs)
|
|
1428
|
+
? Math.max(1000, Math.min(15000, Math.floor(parsedTimeoutMs)))
|
|
1429
|
+
: 5000;
|
|
1430
|
+
const probe = await probeInviteResolutionTarget(target, timeoutMs);
|
|
1431
|
+
res.json({
|
|
1432
|
+
inviteId: invite.id,
|
|
1433
|
+
testResolutionPath: `/api/invites/${token}/test-resolution`,
|
|
1434
|
+
requestedUrl: target.toString(),
|
|
1435
|
+
timeoutMs,
|
|
1436
|
+
...probe
|
|
1437
|
+
});
|
|
1438
|
+
});
|
|
500
1439
|
router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => {
|
|
501
1440
|
const token = req.params.token.trim();
|
|
502
1441
|
if (!token)
|
|
@@ -506,14 +1445,25 @@ export function accessRoutes(db, opts) {
|
|
|
506
1445
|
.from(invites)
|
|
507
1446
|
.where(eq(invites.tokenHash, hashToken(token)))
|
|
508
1447
|
.then((rows) => rows[0] ?? null);
|
|
509
|
-
if (!invite || invite.revokedAt ||
|
|
1448
|
+
if (!invite || invite.revokedAt || inviteExpired(invite)) {
|
|
510
1449
|
throw notFound("Invite not found");
|
|
511
1450
|
}
|
|
1451
|
+
const inviteAlreadyAccepted = Boolean(invite.acceptedAt);
|
|
1452
|
+
const existingJoinRequestForInvite = inviteAlreadyAccepted
|
|
1453
|
+
? await db
|
|
1454
|
+
.select()
|
|
1455
|
+
.from(joinRequests)
|
|
1456
|
+
.where(eq(joinRequests.inviteId, invite.id))
|
|
1457
|
+
.then((rows) => rows[0] ?? null)
|
|
1458
|
+
: null;
|
|
512
1459
|
if (invite.inviteType === "bootstrap_ceo") {
|
|
1460
|
+
if (inviteAlreadyAccepted)
|
|
1461
|
+
throw notFound("Invite not found");
|
|
513
1462
|
if (req.body.requestType !== "human") {
|
|
514
1463
|
throw badRequest("Bootstrap invite requires human request type");
|
|
515
1464
|
}
|
|
516
|
-
if (req.actor.type !== "board" ||
|
|
1465
|
+
if (req.actor.type !== "board" ||
|
|
1466
|
+
(!req.actor.userId && !isLocalImplicit(req))) {
|
|
517
1467
|
throw unauthorized("Authenticated user required for bootstrap acceptance");
|
|
518
1468
|
}
|
|
519
1469
|
const userId = req.actor.userId ?? "local-board";
|
|
@@ -531,7 +1481,7 @@ export function accessRoutes(db, opts) {
|
|
|
531
1481
|
inviteId: updatedInvite.id,
|
|
532
1482
|
inviteType: updatedInvite.inviteType,
|
|
533
1483
|
bootstrapAccepted: true,
|
|
534
|
-
userId
|
|
1484
|
+
userId
|
|
535
1485
|
});
|
|
536
1486
|
return;
|
|
537
1487
|
}
|
|
@@ -539,70 +1489,235 @@ export function accessRoutes(db, opts) {
|
|
|
539
1489
|
const companyId = invite.companyId;
|
|
540
1490
|
if (!companyId)
|
|
541
1491
|
throw conflict("Invite is missing company scope");
|
|
542
|
-
if (invite.allowedJoinTypes !== "both" &&
|
|
1492
|
+
if (invite.allowedJoinTypes !== "both" &&
|
|
1493
|
+
invite.allowedJoinTypes !== requestType) {
|
|
543
1494
|
throw badRequest(`Invite does not allow ${requestType} joins`);
|
|
544
1495
|
}
|
|
545
1496
|
if (requestType === "human" && req.actor.type !== "board") {
|
|
546
1497
|
throw unauthorized("Human invite acceptance requires authenticated user");
|
|
547
1498
|
}
|
|
548
|
-
if (requestType === "human" &&
|
|
1499
|
+
if (requestType === "human" &&
|
|
1500
|
+
!req.actor.userId &&
|
|
1501
|
+
!isLocalImplicit(req)) {
|
|
549
1502
|
throw unauthorized("Authenticated user is required");
|
|
550
1503
|
}
|
|
551
1504
|
if (requestType === "agent" && !req.body.agentName) {
|
|
552
|
-
|
|
1505
|
+
if (!inviteAlreadyAccepted ||
|
|
1506
|
+
!existingJoinRequestForInvite?.agentName) {
|
|
1507
|
+
throw badRequest("agentName is required for agent join requests");
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
const adapterType = req.body.adapterType ?? null;
|
|
1511
|
+
if (inviteAlreadyAccepted &&
|
|
1512
|
+
!canReplayOpenClawGatewayInviteAccept({
|
|
1513
|
+
requestType,
|
|
1514
|
+
adapterType,
|
|
1515
|
+
existingJoinRequest: existingJoinRequestForInvite
|
|
1516
|
+
})) {
|
|
1517
|
+
throw notFound("Invite not found");
|
|
1518
|
+
}
|
|
1519
|
+
const replayJoinRequestId = inviteAlreadyAccepted
|
|
1520
|
+
? existingJoinRequestForInvite?.id ?? null
|
|
1521
|
+
: null;
|
|
1522
|
+
if (inviteAlreadyAccepted && !replayJoinRequestId) {
|
|
1523
|
+
throw conflict("Join request not found");
|
|
553
1524
|
}
|
|
1525
|
+
const replayMergedDefaults = inviteAlreadyAccepted
|
|
1526
|
+
? mergeJoinDefaultsPayloadForReplay(existingJoinRequestForInvite?.agentDefaultsPayload ?? null, req.body.agentDefaultsPayload ?? null)
|
|
1527
|
+
: req.body.agentDefaultsPayload ?? null;
|
|
1528
|
+
const gatewayDefaultsPayload = requestType === "agent"
|
|
1529
|
+
? buildJoinDefaultsPayloadForAccept({
|
|
1530
|
+
adapterType,
|
|
1531
|
+
defaultsPayload: replayMergedDefaults,
|
|
1532
|
+
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
|
1533
|
+
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
|
1534
|
+
inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
|
|
1535
|
+
})
|
|
1536
|
+
: null;
|
|
554
1537
|
const joinDefaults = requestType === "agent"
|
|
555
1538
|
? normalizeAgentDefaultsForJoin({
|
|
556
|
-
adapterType
|
|
557
|
-
defaultsPayload:
|
|
1539
|
+
adapterType,
|
|
1540
|
+
defaultsPayload: gatewayDefaultsPayload,
|
|
558
1541
|
deploymentMode: opts.deploymentMode,
|
|
559
1542
|
deploymentExposure: opts.deploymentExposure,
|
|
560
1543
|
bindHost: opts.bindHost,
|
|
561
|
-
allowedHostnames: opts.allowedHostnames
|
|
1544
|
+
allowedHostnames: opts.allowedHostnames
|
|
562
1545
|
})
|
|
563
|
-
: {
|
|
564
|
-
|
|
1546
|
+
: {
|
|
1547
|
+
normalized: null,
|
|
1548
|
+
diagnostics: [],
|
|
1549
|
+
fatalErrors: []
|
|
1550
|
+
};
|
|
1551
|
+
if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) {
|
|
1552
|
+
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
|
1553
|
+
}
|
|
1554
|
+
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
|
1555
|
+
logger.info({
|
|
1556
|
+
inviteId: invite.id,
|
|
1557
|
+
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
|
|
1558
|
+
code: diag.code,
|
|
1559
|
+
level: diag.level
|
|
1560
|
+
})),
|
|
1561
|
+
normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog(joinDefaults.normalized)
|
|
1562
|
+
}, "invite accept normalized OpenClaw gateway defaults");
|
|
1563
|
+
}
|
|
1564
|
+
const claimSecret = requestType === "agent" && !inviteAlreadyAccepted
|
|
1565
|
+
? createClaimSecret()
|
|
1566
|
+
: null;
|
|
565
1567
|
const claimSecretHash = claimSecret ? hashToken(claimSecret) : null;
|
|
566
1568
|
const claimSecretExpiresAt = claimSecret
|
|
567
1569
|
? new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
568
1570
|
: null;
|
|
569
1571
|
const actorEmail = requestType === "human" ? await resolveActorEmail(db, req) : null;
|
|
570
|
-
const created =
|
|
571
|
-
await tx
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
1572
|
+
const created = !inviteAlreadyAccepted
|
|
1573
|
+
? await db.transaction(async (tx) => {
|
|
1574
|
+
await tx
|
|
1575
|
+
.update(invites)
|
|
1576
|
+
.set({ acceptedAt: new Date(), updatedAt: new Date() })
|
|
1577
|
+
.where(and(eq(invites.id, invite.id), isNull(invites.acceptedAt), isNull(invites.revokedAt)));
|
|
1578
|
+
const row = await tx
|
|
1579
|
+
.insert(joinRequests)
|
|
1580
|
+
.values({
|
|
1581
|
+
inviteId: invite.id,
|
|
1582
|
+
companyId,
|
|
1583
|
+
requestType,
|
|
1584
|
+
status: "pending_approval",
|
|
1585
|
+
requestIp: requestIp(req),
|
|
1586
|
+
requestingUserId: requestType === "human"
|
|
1587
|
+
? req.actor.userId ?? "local-board"
|
|
1588
|
+
: null,
|
|
1589
|
+
requestEmailSnapshot: requestType === "human" ? actorEmail : null,
|
|
1590
|
+
agentName: requestType === "agent" ? req.body.agentName : null,
|
|
1591
|
+
adapterType: requestType === "agent" ? adapterType : null,
|
|
1592
|
+
capabilities: requestType === "agent"
|
|
1593
|
+
? req.body.capabilities ?? null
|
|
1594
|
+
: null,
|
|
1595
|
+
agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null,
|
|
1596
|
+
claimSecretHash,
|
|
1597
|
+
claimSecretExpiresAt
|
|
1598
|
+
})
|
|
1599
|
+
.returning()
|
|
1600
|
+
.then((rows) => rows[0]);
|
|
1601
|
+
return row;
|
|
1602
|
+
})
|
|
1603
|
+
: await db
|
|
1604
|
+
.update(joinRequests)
|
|
1605
|
+
.set({
|
|
582
1606
|
requestIp: requestIp(req),
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1607
|
+
agentName: requestType === "agent"
|
|
1608
|
+
? req.body.agentName ??
|
|
1609
|
+
existingJoinRequestForInvite?.agentName ??
|
|
1610
|
+
null
|
|
1611
|
+
: null,
|
|
1612
|
+
capabilities: requestType === "agent"
|
|
1613
|
+
? req.body.capabilities ??
|
|
1614
|
+
existingJoinRequestForInvite?.capabilities ??
|
|
1615
|
+
null
|
|
1616
|
+
: null,
|
|
1617
|
+
adapterType: requestType === "agent" ? adapterType : null,
|
|
588
1618
|
agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null,
|
|
589
|
-
|
|
590
|
-
claimSecretExpiresAt,
|
|
1619
|
+
updatedAt: new Date()
|
|
591
1620
|
})
|
|
1621
|
+
.where(eq(joinRequests.id, replayJoinRequestId))
|
|
592
1622
|
.returning()
|
|
593
1623
|
.then((rows) => rows[0]);
|
|
594
|
-
|
|
595
|
-
|
|
1624
|
+
if (!created) {
|
|
1625
|
+
throw conflict("Join request not found");
|
|
1626
|
+
}
|
|
1627
|
+
if (inviteAlreadyAccepted &&
|
|
1628
|
+
requestType === "agent" &&
|
|
1629
|
+
adapterType === "openclaw_gateway" &&
|
|
1630
|
+
created.status === "approved" &&
|
|
1631
|
+
created.createdAgentId) {
|
|
1632
|
+
const existingAgent = await agents.getById(created.createdAgentId);
|
|
1633
|
+
if (!existingAgent) {
|
|
1634
|
+
throw conflict("Approved join request agent not found");
|
|
1635
|
+
}
|
|
1636
|
+
const existingAdapterConfig = isPlainObject(existingAgent.adapterConfig)
|
|
1637
|
+
? existingAgent.adapterConfig
|
|
1638
|
+
: {};
|
|
1639
|
+
const nextAdapterConfig = {
|
|
1640
|
+
...existingAdapterConfig,
|
|
1641
|
+
...(joinDefaults.normalized ?? {})
|
|
1642
|
+
};
|
|
1643
|
+
const updatedAgent = await agents.update(created.createdAgentId, {
|
|
1644
|
+
adapterType,
|
|
1645
|
+
adapterConfig: nextAdapterConfig
|
|
1646
|
+
});
|
|
1647
|
+
if (!updatedAgent) {
|
|
1648
|
+
throw conflict("Approved join request agent not found");
|
|
1649
|
+
}
|
|
1650
|
+
await logActivity(db, {
|
|
1651
|
+
companyId,
|
|
1652
|
+
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
1653
|
+
actorId: req.actor.type === "agent"
|
|
1654
|
+
? req.actor.agentId ?? "invite-agent"
|
|
1655
|
+
: req.actor.userId ?? "board",
|
|
1656
|
+
action: "agent.updated_from_join_replay",
|
|
1657
|
+
entityType: "agent",
|
|
1658
|
+
entityId: updatedAgent.id,
|
|
1659
|
+
details: { inviteId: invite.id, joinRequestId: created.id }
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
|
1663
|
+
const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog(joinDefaults.normalized);
|
|
1664
|
+
const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog(created.agentDefaultsPayload);
|
|
1665
|
+
const missingPersistedFields = [];
|
|
1666
|
+
if (expectedDefaults.url && !persistedDefaults.url)
|
|
1667
|
+
missingPersistedFields.push("url");
|
|
1668
|
+
if (expectedDefaults.paperclipApiUrl &&
|
|
1669
|
+
!persistedDefaults.paperclipApiUrl) {
|
|
1670
|
+
missingPersistedFields.push("paperclipApiUrl");
|
|
1671
|
+
}
|
|
1672
|
+
if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) {
|
|
1673
|
+
missingPersistedFields.push("headers.x-openclaw-token");
|
|
1674
|
+
}
|
|
1675
|
+
if (expectedDefaults.devicePrivateKeyPem &&
|
|
1676
|
+
!persistedDefaults.devicePrivateKeyPem) {
|
|
1677
|
+
missingPersistedFields.push("devicePrivateKeyPem");
|
|
1678
|
+
}
|
|
1679
|
+
if (expectedDefaults.headerKeys.length > 0 &&
|
|
1680
|
+
persistedDefaults.headerKeys.length === 0) {
|
|
1681
|
+
missingPersistedFields.push("headers");
|
|
1682
|
+
}
|
|
1683
|
+
logger.info({
|
|
1684
|
+
inviteId: invite.id,
|
|
1685
|
+
joinRequestId: created.id,
|
|
1686
|
+
joinRequestStatus: created.status,
|
|
1687
|
+
expectedDefaults,
|
|
1688
|
+
persistedDefaults,
|
|
1689
|
+
diagnostics: joinDefaults.diagnostics.map((diag) => ({
|
|
1690
|
+
code: diag.code,
|
|
1691
|
+
level: diag.level,
|
|
1692
|
+
message: diag.message,
|
|
1693
|
+
hint: diag.hint ?? null
|
|
1694
|
+
}))
|
|
1695
|
+
}, "invite accept persisted OpenClaw gateway join request");
|
|
1696
|
+
if (missingPersistedFields.length > 0) {
|
|
1697
|
+
logger.warn({
|
|
1698
|
+
inviteId: invite.id,
|
|
1699
|
+
joinRequestId: created.id,
|
|
1700
|
+
missingPersistedFields
|
|
1701
|
+
}, "invite accept detected missing persisted OpenClaw gateway defaults");
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
596
1704
|
await logActivity(db, {
|
|
597
1705
|
companyId,
|
|
598
1706
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
599
1707
|
actorId: req.actor.type === "agent"
|
|
600
1708
|
? req.actor.agentId ?? "invite-agent"
|
|
601
|
-
: req.actor.userId ??
|
|
602
|
-
|
|
1709
|
+
: req.actor.userId ??
|
|
1710
|
+
(requestType === "agent" ? "invite-anon" : "board"),
|
|
1711
|
+
action: inviteAlreadyAccepted
|
|
1712
|
+
? "join.request_replayed"
|
|
1713
|
+
: "join.requested",
|
|
603
1714
|
entityType: "join_request",
|
|
604
1715
|
entityId: created.id,
|
|
605
|
-
details: {
|
|
1716
|
+
details: {
|
|
1717
|
+
requestType,
|
|
1718
|
+
requestIp: created.requestIp,
|
|
1719
|
+
inviteReplay: inviteAlreadyAccepted
|
|
1720
|
+
}
|
|
606
1721
|
});
|
|
607
1722
|
const response = toJoinRequestResponse(created);
|
|
608
1723
|
if (claimSecret) {
|
|
@@ -612,18 +1727,24 @@ export function accessRoutes(db, opts) {
|
|
|
612
1727
|
claimSecret,
|
|
613
1728
|
claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`,
|
|
614
1729
|
onboarding: onboardingManifest.onboarding,
|
|
615
|
-
diagnostics: joinDefaults.diagnostics
|
|
1730
|
+
diagnostics: joinDefaults.diagnostics
|
|
616
1731
|
});
|
|
617
1732
|
return;
|
|
618
1733
|
}
|
|
619
1734
|
res.status(202).json({
|
|
620
1735
|
...response,
|
|
621
|
-
...(joinDefaults.diagnostics.length > 0
|
|
1736
|
+
...(joinDefaults.diagnostics.length > 0
|
|
1737
|
+
? { diagnostics: joinDefaults.diagnostics }
|
|
1738
|
+
: {})
|
|
622
1739
|
});
|
|
623
1740
|
});
|
|
624
1741
|
router.post("/invites/:inviteId/revoke", async (req, res) => {
|
|
625
1742
|
const id = req.params.inviteId;
|
|
626
|
-
const invite = await db
|
|
1743
|
+
const invite = await db
|
|
1744
|
+
.select()
|
|
1745
|
+
.from(invites)
|
|
1746
|
+
.where(eq(invites.id, id))
|
|
1747
|
+
.then((rows) => rows[0] ?? null);
|
|
627
1748
|
if (!invite)
|
|
628
1749
|
throw notFound("Invite not found");
|
|
629
1750
|
if (invite.inviteType === "bootstrap_ceo") {
|
|
@@ -648,10 +1769,12 @@ export function accessRoutes(db, opts) {
|
|
|
648
1769
|
await logActivity(db, {
|
|
649
1770
|
companyId: invite.companyId,
|
|
650
1771
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
|
651
|
-
actorId: req.actor.type === "agent"
|
|
1772
|
+
actorId: req.actor.type === "agent"
|
|
1773
|
+
? req.actor.agentId ?? "unknown-agent"
|
|
1774
|
+
: req.actor.userId ?? "board",
|
|
652
1775
|
action: "invite.revoked",
|
|
653
1776
|
entityType: "invite",
|
|
654
|
-
entityId: id
|
|
1777
|
+
entityId: id
|
|
655
1778
|
});
|
|
656
1779
|
}
|
|
657
1780
|
res.json(revoked);
|
|
@@ -703,15 +1826,26 @@ export function accessRoutes(db, opts) {
|
|
|
703
1826
|
await access.setPrincipalGrants(companyId, "user", existing.requestingUserId, grants, req.actor.userId ?? null);
|
|
704
1827
|
}
|
|
705
1828
|
else {
|
|
1829
|
+
const existingAgents = await agents.list(companyId);
|
|
1830
|
+
const managerId = resolveJoinRequestAgentManagerId(existingAgents);
|
|
1831
|
+
if (!managerId) {
|
|
1832
|
+
throw conflict("Join request cannot be approved because this company has no active CEO");
|
|
1833
|
+
}
|
|
1834
|
+
const agentName = deduplicateAgentName(existing.agentName ?? "New Agent", existingAgents.map((a) => ({
|
|
1835
|
+
id: a.id,
|
|
1836
|
+
name: a.name,
|
|
1837
|
+
status: a.status
|
|
1838
|
+
})));
|
|
706
1839
|
const created = await agents.create(companyId, {
|
|
707
|
-
name:
|
|
1840
|
+
name: agentName,
|
|
708
1841
|
role: "general",
|
|
709
1842
|
title: null,
|
|
710
1843
|
status: "idle",
|
|
711
|
-
reportsTo:
|
|
1844
|
+
reportsTo: managerId,
|
|
712
1845
|
capabilities: existing.capabilities ?? null,
|
|
713
1846
|
adapterType: existing.adapterType ?? "process",
|
|
714
|
-
adapterConfig: existing.agentDefaultsPayload &&
|
|
1847
|
+
adapterConfig: existing.agentDefaultsPayload &&
|
|
1848
|
+
typeof existing.agentDefaultsPayload === "object"
|
|
715
1849
|
? existing.agentDefaultsPayload
|
|
716
1850
|
: {},
|
|
717
1851
|
runtimeConfig: {},
|
|
@@ -719,7 +1853,7 @@ export function accessRoutes(db, opts) {
|
|
|
719
1853
|
spentMonthlyCents: 0,
|
|
720
1854
|
permissions: {},
|
|
721
1855
|
lastHeartbeatAt: null,
|
|
722
|
-
metadata: null
|
|
1856
|
+
metadata: null
|
|
723
1857
|
});
|
|
724
1858
|
createdAgentId = created.id;
|
|
725
1859
|
await access.ensureMembership(companyId, "agent", created.id, "member", "active");
|
|
@@ -733,7 +1867,7 @@ export function accessRoutes(db, opts) {
|
|
|
733
1867
|
approvedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
|
|
734
1868
|
approvedAt: new Date(),
|
|
735
1869
|
createdAgentId,
|
|
736
|
-
updatedAt: new Date()
|
|
1870
|
+
updatedAt: new Date()
|
|
737
1871
|
})
|
|
738
1872
|
.where(eq(joinRequests.id, requestId))
|
|
739
1873
|
.returning()
|
|
@@ -745,8 +1879,17 @@ export function accessRoutes(db, opts) {
|
|
|
745
1879
|
action: "join.approved",
|
|
746
1880
|
entityType: "join_request",
|
|
747
1881
|
entityId: requestId,
|
|
748
|
-
details: { requestType: existing.requestType, createdAgentId }
|
|
1882
|
+
details: { requestType: existing.requestType, createdAgentId }
|
|
749
1883
|
});
|
|
1884
|
+
if (createdAgentId) {
|
|
1885
|
+
void notifyHireApproved(db, {
|
|
1886
|
+
companyId,
|
|
1887
|
+
agentId: createdAgentId,
|
|
1888
|
+
source: "join_request",
|
|
1889
|
+
sourceId: requestId,
|
|
1890
|
+
approvedAt: new Date()
|
|
1891
|
+
}).catch(() => { });
|
|
1892
|
+
}
|
|
750
1893
|
res.json(toJoinRequestResponse(approved));
|
|
751
1894
|
});
|
|
752
1895
|
router.post("/companies/:companyId/join-requests/:requestId/reject", async (req, res) => {
|
|
@@ -768,7 +1911,7 @@ export function accessRoutes(db, opts) {
|
|
|
768
1911
|
status: "rejected",
|
|
769
1912
|
rejectedByUserId: req.actor.userId ?? (isLocalImplicit(req) ? "local-board" : null),
|
|
770
1913
|
rejectedAt: new Date(),
|
|
771
|
-
updatedAt: new Date()
|
|
1914
|
+
updatedAt: new Date()
|
|
772
1915
|
})
|
|
773
1916
|
.where(eq(joinRequests.id, requestId))
|
|
774
1917
|
.returning()
|
|
@@ -780,7 +1923,7 @@ export function accessRoutes(db, opts) {
|
|
|
780
1923
|
action: "join.rejected",
|
|
781
1924
|
entityType: "join_request",
|
|
782
1925
|
entityId: requestId,
|
|
783
|
-
details: { requestType: existing.requestType }
|
|
1926
|
+
details: { requestType: existing.requestType }
|
|
784
1927
|
});
|
|
785
1928
|
res.json(toJoinRequestResponse(rejected));
|
|
786
1929
|
});
|
|
@@ -805,7 +1948,8 @@ export function accessRoutes(db, opts) {
|
|
|
805
1948
|
if (!tokenHashesMatch(joinRequest.claimSecretHash, presentedClaimSecretHash)) {
|
|
806
1949
|
throw forbidden("Invalid claim secret");
|
|
807
1950
|
}
|
|
808
|
-
if (joinRequest.claimSecretExpiresAt &&
|
|
1951
|
+
if (joinRequest.claimSecretExpiresAt &&
|
|
1952
|
+
joinRequest.claimSecretExpiresAt.getTime() <= Date.now()) {
|
|
809
1953
|
throw conflict("Claim secret expired");
|
|
810
1954
|
}
|
|
811
1955
|
if (joinRequest.claimSecretConsumedAt)
|
|
@@ -833,13 +1977,16 @@ export function accessRoutes(db, opts) {
|
|
|
833
1977
|
action: "agent_api_key.claimed",
|
|
834
1978
|
entityType: "agent_api_key",
|
|
835
1979
|
entityId: created.id,
|
|
836
|
-
details: {
|
|
1980
|
+
details: {
|
|
1981
|
+
agentId: joinRequest.createdAgentId,
|
|
1982
|
+
joinRequestId: requestId
|
|
1983
|
+
}
|
|
837
1984
|
});
|
|
838
1985
|
res.status(201).json({
|
|
839
1986
|
keyId: created.id,
|
|
840
1987
|
token: created.token,
|
|
841
1988
|
agentId: joinRequest.createdAgentId,
|
|
842
|
-
createdAt: created.createdAt
|
|
1989
|
+
createdAt: created.createdAt
|
|
843
1990
|
});
|
|
844
1991
|
});
|
|
845
1992
|
router.get("/companies/:companyId/members", async (req, res) => {
|