@kodelyth/tlon 2026.5.39 → 2026.5.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +5 -0
  2. package/api.ts +16 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/dist/api.js +4 -0
  5. package/dist/channel-Bvzym9ez.js +236 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/channel.runtime-CDY2BdfM.js +3626 -0
  8. package/dist/doctor-contract-Ip6FcHDH.js +7 -0
  9. package/dist/doctor-contract-api.js +2 -0
  10. package/dist/index.js +18 -0
  11. package/dist/runtime-BmSb9A-q.js +8 -0
  12. package/dist/runtime-api-Dq8wkBC_.js +4 -0
  13. package/dist/runtime-api.js +2 -0
  14. package/dist/setup-api.js +3 -0
  15. package/dist/setup-core-CF3ryHqs.js +387 -0
  16. package/dist/setup-entry.js +11 -0
  17. package/dist/setup-surface-BM5_V_XL.js +74 -0
  18. package/dist/test-api.js +2 -0
  19. package/doctor-contract-api.ts +1 -0
  20. package/index.ts +16 -0
  21. package/klaw.plugin.json +3 -203
  22. package/package.json +4 -4
  23. package/runtime-api.ts +17 -0
  24. package/setup-api.ts +2 -0
  25. package/setup-entry.ts +9 -0
  26. package/src/account-fields.ts +31 -0
  27. package/src/channel.message-adapter.test.ts +145 -0
  28. package/src/channel.runtime.ts +259 -0
  29. package/src/channel.ts +192 -0
  30. package/src/config-schema.ts +54 -0
  31. package/src/core.test.ts +298 -0
  32. package/src/doctor-contract.ts +9 -0
  33. package/src/doctor.test.ts +46 -0
  34. package/src/doctor.ts +10 -0
  35. package/src/logger-runtime.ts +1 -0
  36. package/src/monitor/approval-runtime.ts +363 -0
  37. package/src/monitor/approval.test.ts +33 -0
  38. package/src/monitor/approval.ts +283 -0
  39. package/src/monitor/authorization.ts +30 -0
  40. package/src/monitor/cites.ts +54 -0
  41. package/src/monitor/discovery.ts +68 -0
  42. package/src/monitor/history.ts +226 -0
  43. package/src/monitor/index.ts +1523 -0
  44. package/src/monitor/media.test.ts +80 -0
  45. package/src/monitor/media.ts +156 -0
  46. package/src/monitor/processed-messages.test.ts +58 -0
  47. package/src/monitor/processed-messages.ts +89 -0
  48. package/src/monitor/settings-helpers.test.ts +113 -0
  49. package/src/monitor/settings-helpers.ts +158 -0
  50. package/src/monitor/utils.ts +402 -0
  51. package/src/runtime.ts +9 -0
  52. package/src/security.test.ts +658 -0
  53. package/src/session-route.ts +40 -0
  54. package/src/settings.ts +391 -0
  55. package/src/setup-core.ts +231 -0
  56. package/src/setup-surface.ts +99 -0
  57. package/src/targets.ts +102 -0
  58. package/src/tlon-api.test.ts +572 -0
  59. package/src/tlon-api.ts +389 -0
  60. package/src/types.ts +160 -0
  61. package/src/urbit/auth.ssrf.test.ts +45 -0
  62. package/src/urbit/auth.ts +48 -0
  63. package/src/urbit/base-url.test.ts +48 -0
  64. package/src/urbit/base-url.ts +61 -0
  65. package/src/urbit/channel-ops.test.ts +36 -0
  66. package/src/urbit/channel-ops.ts +149 -0
  67. package/src/urbit/context.ts +50 -0
  68. package/src/urbit/errors.ts +51 -0
  69. package/src/urbit/fetch.ts +38 -0
  70. package/src/urbit/foreigns.ts +49 -0
  71. package/src/urbit/send.test.ts +83 -0
  72. package/src/urbit/send.ts +228 -0
  73. package/src/urbit/sse-client.test.ts +234 -0
  74. package/src/urbit/sse-client.ts +492 -0
  75. package/src/urbit/story.ts +332 -0
  76. package/src/urbit/upload.test.ts +155 -0
  77. package/src/urbit/upload.ts +60 -0
  78. package/test-api.ts +1 -0
  79. package/tsconfig.json +16 -0
  80. package/api.js +0 -7
  81. package/bundled-skills/@tloncorp/tlon-skill/SKILL.md +0 -501
  82. package/bundled-skills/@tloncorp/tlon-skill/bin/tlon.js +0 -7
  83. package/bundled-skills/@tloncorp/tlon-skill/package.json +0 -40
  84. package/bundled-skills/@tloncorp/tlon-skill/scripts/postinstall.js +0 -7
  85. package/channel-plugin-api.js +0 -7
  86. package/doctor-contract-api.js +0 -7
  87. package/index.js +0 -7
  88. package/runtime-api.js +0 -7
  89. package/setup-api.js +0 -7
  90. package/setup-entry.js +0 -7
  91. package/test-api.js +0 -7
@@ -0,0 +1,389 @@
1
+ import crypto from "node:crypto";
2
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
3
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
4
+ import { extensionForMime } from "klaw/plugin-sdk/media-mime";
5
+ import { fetchWithSsrFGuard } from "klaw/plugin-sdk/ssrf-runtime";
6
+ import { authenticate } from "./urbit/auth.js";
7
+ import { scryUrbitPath } from "./urbit/channel-ops.js";
8
+ import { ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "./urbit/context.js";
9
+
10
+ type ClientConfig = {
11
+ shipUrl: string;
12
+ shipName: string;
13
+ verbose: boolean;
14
+ getCode: () => Promise<string>;
15
+ dangerouslyAllowPrivateNetwork?: boolean;
16
+ };
17
+
18
+ type StorageService = "presigned-url" | "credentials";
19
+
20
+ type StorageConfiguration = {
21
+ buckets: string[];
22
+ currentBucket: string;
23
+ region: string;
24
+ publicUrlBase: string;
25
+ presignedUrl: string;
26
+ service: StorageService;
27
+ };
28
+
29
+ type StorageCredentials = {
30
+ endpoint: string;
31
+ accessKeyId: string;
32
+ secretAccessKey: string;
33
+ };
34
+
35
+ type UploadFileParams = {
36
+ blob: Blob;
37
+ fileName?: string;
38
+ contentType?: string;
39
+ };
40
+
41
+ type UploadResult = {
42
+ url: string;
43
+ };
44
+
45
+ const MEMEX_BASE_URL = "https://memex.tlon.network";
46
+
47
+ let currentClientConfig: ClientConfig | null = null;
48
+
49
+ export function configureClient(params: ClientConfig): void {
50
+ currentClientConfig = {
51
+ ...params,
52
+ shipName: params.shipName.replace(/^~/, ""),
53
+ };
54
+ }
55
+
56
+ function requireClientConfig(): ClientConfig {
57
+ if (!currentClientConfig) {
58
+ throw new Error("Tlon client not configured");
59
+ }
60
+ return currentClientConfig;
61
+ }
62
+
63
+ function getExtensionFromMimeType(mimeType?: string): string {
64
+ return extensionForMime(mimeType) || ".jpg";
65
+ }
66
+
67
+ function hasCustomS3Creds(
68
+ credentials: StorageCredentials | null,
69
+ ): credentials is StorageCredentials {
70
+ return Boolean(credentials?.accessKeyId && credentials?.endpoint && credentials?.secretAccessKey);
71
+ }
72
+
73
+ function isStorageCredentials(value: unknown): value is StorageCredentials {
74
+ if (!value || typeof value !== "object") {
75
+ return false;
76
+ }
77
+ const record = value as Record<string, unknown>;
78
+ return (
79
+ typeof record.endpoint === "string" &&
80
+ typeof record.accessKeyId === "string" &&
81
+ typeof record.secretAccessKey === "string"
82
+ );
83
+ }
84
+
85
+ function hostnameMatchesDomainBoundary(hostname: string, domain: string): boolean {
86
+ return hostname === domain || hostname.endsWith(`.${domain}`);
87
+ }
88
+
89
+ function isHostedShipUrl(shipUrl: string): boolean {
90
+ const hostname = extractShipHostname(shipUrl);
91
+ return hostname !== null && isHostedTlonHostname(hostname);
92
+ }
93
+
94
+ function extractShipHostname(shipUrl: string): string | null {
95
+ const trimmed = shipUrl.trim();
96
+ if (!trimmed) {
97
+ return null;
98
+ }
99
+ const normalized = /^[a-zA-Z][\w+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
100
+ try {
101
+ return new URL(normalized).hostname;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function isHostedTlonHostname(hostname: string): boolean {
108
+ return (
109
+ hostnameMatchesDomainBoundary(hostname, "tlon.network") ||
110
+ hostnameMatchesDomainBoundary(hostname, "test.tlon.systems")
111
+ );
112
+ }
113
+
114
+ function assertTrustedMemexUploadUrl(rawUrl: string, label: string): string {
115
+ let parsed: URL;
116
+ try {
117
+ parsed = new URL(rawUrl);
118
+ } catch {
119
+ throw new Error(`${label} must be a valid https URL`);
120
+ }
121
+
122
+ if (parsed.protocol !== "https:") {
123
+ throw new Error(`${label} must use https`);
124
+ }
125
+
126
+ if (!isHostedTlonHostname(parsed.hostname)) {
127
+ throw new Error(`${label} must target a trusted hosted Tlon domain`);
128
+ }
129
+
130
+ if (parsed.port && parsed.port !== "443") {
131
+ throw new Error(`${label} must not specify a non-standard port`);
132
+ }
133
+
134
+ return parsed.toString();
135
+ }
136
+
137
+ function assertSafeUploadResultUrl(rawUrl: string, label: string): string {
138
+ let parsed: URL;
139
+ try {
140
+ parsed = new URL(rawUrl);
141
+ } catch {
142
+ throw new Error(`${label} must be a valid http(s) URL`);
143
+ }
144
+
145
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
146
+ throw new Error(`${label} must use http or https`);
147
+ }
148
+
149
+ return parsed.toString();
150
+ }
151
+
152
+ function prefixEndpoint(endpoint: string): string {
153
+ return /https?:\/\//.test(endpoint) ? endpoint : `https://${endpoint}`;
154
+ }
155
+
156
+ function sanitizeFileName(fileName: string): string {
157
+ return fileName.split(/[/\\]/).pop() || fileName;
158
+ }
159
+
160
+ async function getAuthCookie(config: ClientConfig): Promise<string> {
161
+ return await authenticate(config.shipUrl, await config.getCode(), {
162
+ ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(config.dangerouslyAllowPrivateNetwork),
163
+ });
164
+ }
165
+
166
+ async function scryJson<T>(config: ClientConfig, cookie: string, path: string): Promise<T> {
167
+ return (await scryUrbitPath(
168
+ {
169
+ baseUrl: config.shipUrl,
170
+ cookie,
171
+ ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(
172
+ config.dangerouslyAllowPrivateNetwork,
173
+ ),
174
+ },
175
+ { path, auditContext: "tlon-storage-scry" },
176
+ )) as T;
177
+ }
178
+
179
+ async function getStorageConfiguration(
180
+ config: ClientConfig,
181
+ cookie: string,
182
+ ): Promise<StorageConfiguration> {
183
+ const result = await scryJson<
184
+ { "storage-update"?: { configuration?: StorageConfiguration } } | StorageConfiguration
185
+ >(config, cookie, "/storage/configuration.json");
186
+
187
+ if ("storage-update" in result && result["storage-update"]?.configuration) {
188
+ return result["storage-update"].configuration;
189
+ }
190
+ if ("currentBucket" in result) {
191
+ return result;
192
+ }
193
+ throw new Error("Invalid storage configuration response");
194
+ }
195
+
196
+ async function getStorageCredentials(
197
+ config: ClientConfig,
198
+ cookie: string,
199
+ ): Promise<StorageCredentials | null> {
200
+ const result = await scryJson<
201
+ { "storage-update"?: { credentials?: StorageCredentials } } | StorageCredentials
202
+ >(config, cookie, "/storage/credentials.json");
203
+
204
+ if ("storage-update" in result) {
205
+ return result["storage-update"]?.credentials ?? null;
206
+ }
207
+ if (isStorageCredentials(result)) {
208
+ return result;
209
+ }
210
+ return null;
211
+ }
212
+
213
+ async function getMemexUploadUrl(params: {
214
+ config: ClientConfig;
215
+ cookie: string;
216
+ contentLength: number;
217
+ contentType: string;
218
+ fileName: string;
219
+ }): Promise<{ hostedUrl: string; uploadUrl: string }> {
220
+ const token = await scryJson<string | { secret?: string }>(
221
+ params.config,
222
+ params.cookie,
223
+ "/genuine/secret.json",
224
+ );
225
+ const resolvedToken = typeof token === "string" ? token : token.secret;
226
+ if (!resolvedToken) {
227
+ throw new Error("Missing genuine secret");
228
+ }
229
+
230
+ const endpoint = `${MEMEX_BASE_URL}/v1/${params.config.shipName}/upload`;
231
+ let release: (() => Promise<void>) | undefined;
232
+ try {
233
+ const guarded = await fetchWithSsrFGuard({
234
+ url: endpoint,
235
+ init: {
236
+ method: "PUT",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({
239
+ token: resolvedToken,
240
+ contentLength: params.contentLength,
241
+ contentType: params.contentType,
242
+ fileName: params.fileName,
243
+ }),
244
+ },
245
+ auditContext: "tlon-memex-upload-url",
246
+ capture: false,
247
+ maxRedirects: 0,
248
+ });
249
+ release = guarded.release;
250
+ if (!guarded.response.ok) {
251
+ throw new Error(`Memex upload request failed: ${guarded.response.status}`);
252
+ }
253
+
254
+ const data = (await guarded.response.json()) as { url?: string; filePath?: string } | null;
255
+ if (!data?.url || !data.filePath) {
256
+ throw new Error("Invalid response from Memex");
257
+ }
258
+
259
+ return { hostedUrl: data.filePath, uploadUrl: data.url };
260
+ } finally {
261
+ await release?.();
262
+ }
263
+ }
264
+
265
+ export async function uploadFile(params: UploadFileParams): Promise<UploadResult> {
266
+ const config = requireClientConfig();
267
+ const cookie = await getAuthCookie(config);
268
+ const privateNetworkPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(
269
+ config.dangerouslyAllowPrivateNetwork,
270
+ );
271
+
272
+ const [storageConfig, credentials] = await Promise.all([
273
+ getStorageConfiguration(config, cookie),
274
+ getStorageCredentials(config, cookie),
275
+ ]);
276
+
277
+ const contentType = params.contentType || params.blob.type || "application/octet-stream";
278
+ const extension = getExtensionFromMimeType(contentType);
279
+ const fileName = sanitizeFileName(params.fileName || `upload${extension}`);
280
+ const fileKey = `${config.shipName}/${Date.now()}-${crypto.randomUUID()}-${fileName}`;
281
+
282
+ const useMemex =
283
+ isHostedShipUrl(config.shipUrl) &&
284
+ (storageConfig.service === "presigned-url" || !hasCustomS3Creds(credentials));
285
+
286
+ if (useMemex) {
287
+ const { hostedUrl, uploadUrl } = await getMemexUploadUrl({
288
+ config,
289
+ cookie,
290
+ contentLength: params.blob.size,
291
+ contentType,
292
+ fileName: fileKey,
293
+ });
294
+ const trustedUploadUrl = assertTrustedMemexUploadUrl(uploadUrl, "Memex upload URL");
295
+
296
+ let release: (() => Promise<void>) | undefined;
297
+ try {
298
+ const guarded = await fetchWithSsrFGuard({
299
+ url: trustedUploadUrl,
300
+ init: {
301
+ method: "PUT",
302
+ body: params.blob,
303
+ headers: {
304
+ "Cache-Control": "public, max-age=3600",
305
+ "Content-Type": contentType,
306
+ },
307
+ },
308
+ auditContext: "tlon-memex-upload",
309
+ capture: false,
310
+ maxRedirects: 0,
311
+ });
312
+ release = guarded.release;
313
+ assertTrustedMemexUploadUrl(guarded.finalUrl, "Memex final upload URL");
314
+ if (!guarded.response.ok) {
315
+ throw new Error(`Upload failed: ${guarded.response.status}`);
316
+ }
317
+ } finally {
318
+ await release?.();
319
+ }
320
+
321
+ return { url: assertTrustedMemexUploadUrl(hostedUrl, "Memex hosted URL") };
322
+ }
323
+
324
+ if (!hasCustomS3Creds(credentials)) {
325
+ throw new Error("No storage credentials configured");
326
+ }
327
+
328
+ const endpoint = new URL(prefixEndpoint(credentials.endpoint));
329
+ const client = new S3Client({
330
+ endpoint: {
331
+ protocol: endpoint.protocol.slice(0, -1) as "http" | "https",
332
+ hostname: endpoint.host,
333
+ path: endpoint.pathname || "/",
334
+ },
335
+ region: storageConfig.region || "us-east-1",
336
+ credentials: {
337
+ accessKeyId: credentials.accessKeyId,
338
+ secretAccessKey: credentials.secretAccessKey,
339
+ },
340
+ forcePathStyle: true,
341
+ });
342
+
343
+ const headers: Record<string, string> = {
344
+ "Cache-Control": "public, max-age=3600",
345
+ "Content-Type": contentType,
346
+ "x-amz-acl": "public-read",
347
+ };
348
+
349
+ const command = new PutObjectCommand({
350
+ Bucket: storageConfig.currentBucket,
351
+ Key: fileKey,
352
+ ContentType: headers["Content-Type"],
353
+ CacheControl: headers["Cache-Control"],
354
+ ACL: "public-read",
355
+ });
356
+
357
+ const signedUrl = await getSignedUrl(client, command, {
358
+ expiresIn: 3600,
359
+ signableHeaders: new Set(Object.keys(headers)),
360
+ });
361
+
362
+ let release: (() => Promise<void>) | undefined;
363
+ try {
364
+ const guarded = await fetchWithSsrFGuard({
365
+ url: signedUrl,
366
+ init: {
367
+ method: "PUT",
368
+ body: params.blob,
369
+ headers: signedUrl.includes("digitaloceanspaces.com") ? headers : undefined,
370
+ },
371
+ auditContext: "tlon-custom-s3-upload",
372
+ capture: false,
373
+ maxRedirects: 0,
374
+ policy: privateNetworkPolicy,
375
+ });
376
+ release = guarded.release;
377
+ if (!guarded.response.ok) {
378
+ throw new Error(`Upload failed: ${guarded.response.status}`);
379
+ }
380
+ } finally {
381
+ await release?.();
382
+ }
383
+
384
+ const publicUrl = storageConfig.publicUrlBase
385
+ ? new URL(fileKey, storageConfig.publicUrlBase).toString()
386
+ : signedUrl.split("?")[0];
387
+
388
+ return { url: assertSafeUploadResultUrl(publicUrl, "Upload result URL") };
389
+ }
package/src/types.ts ADDED
@@ -0,0 +1,160 @@
1
+ import {
2
+ DEFAULT_ACCOUNT_ID,
3
+ listCombinedAccountIds,
4
+ normalizeAccountId,
5
+ resolveMergedAccountConfig,
6
+ } from "klaw/plugin-sdk/account-resolution";
7
+ import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
8
+ import {
9
+ hasLegacyFlatAllowPrivateNetworkAlias,
10
+ isPrivateNetworkOptInEnabled,
11
+ } from "klaw/plugin-sdk/ssrf-runtime";
12
+
13
+ type TlonAccountConfig = {
14
+ name?: string;
15
+ enabled?: boolean;
16
+ ship?: string;
17
+ url?: string;
18
+ code?: string;
19
+ network?: {
20
+ dangerouslyAllowPrivateNetwork?: boolean;
21
+ };
22
+ groupChannels?: string[];
23
+ dmAllowlist?: string[];
24
+ groupInviteAllowlist?: string[];
25
+ autoDiscoverChannels?: boolean;
26
+ showModelSignature?: boolean;
27
+ autoAcceptDmInvites?: boolean;
28
+ autoAcceptGroupInvites?: boolean;
29
+ defaultAuthorizedShips?: string[];
30
+ ownerShip?: string;
31
+ accounts?: Record<string, TlonAccountConfig>;
32
+ };
33
+
34
+ export type TlonResolvedAccount = {
35
+ accountId: string;
36
+ name: string | null;
37
+ enabled: boolean;
38
+ configured: boolean;
39
+ ship: string | null;
40
+ url: string | null;
41
+ code: string | null;
42
+ dangerouslyAllowPrivateNetwork: boolean | null;
43
+ groupChannels: string[];
44
+ dmAllowlist: string[];
45
+ /** Ships allowed to invite us to groups (security: prevent malicious group invites) */
46
+ groupInviteAllowlist: string[];
47
+ autoDiscoverChannels: boolean | null;
48
+ showModelSignature: boolean | null;
49
+ autoAcceptDmInvites: boolean | null;
50
+ autoAcceptGroupInvites: boolean | null;
51
+ defaultAuthorizedShips: string[];
52
+ /** Ship that receives approval requests for DMs, channel mentions, and group invites */
53
+ ownerShip: string | null;
54
+ };
55
+
56
+ function resolveTlonChannelConfig(cfg: KlawConfig): TlonAccountConfig | undefined {
57
+ return cfg.channels?.tlon as TlonAccountConfig | undefined;
58
+ }
59
+
60
+ function resolveMergedTlonAccountConfig(
61
+ cfg: KlawConfig,
62
+ accountId: string,
63
+ ): Record<string, unknown> & TlonAccountConfig {
64
+ const channel = resolveTlonChannelConfig(cfg);
65
+ if (accountId === DEFAULT_ACCOUNT_ID) {
66
+ return (channel ?? {}) as Record<string, unknown> & TlonAccountConfig;
67
+ }
68
+ return resolveMergedAccountConfig<Record<string, unknown> & TlonAccountConfig>({
69
+ channelConfig: (channel ?? {}) as Record<string, unknown> & TlonAccountConfig,
70
+ accounts: channel?.accounts as
71
+ | Record<string, Partial<Record<string, unknown> & TlonAccountConfig>>
72
+ | undefined,
73
+ accountId,
74
+ normalizeAccountId,
75
+ });
76
+ }
77
+
78
+ export function resolveTlonAccount(
79
+ cfg: KlawConfig,
80
+ accountId?: string | null,
81
+ ): TlonResolvedAccount {
82
+ const resolvedAccountId = normalizeAccountId(accountId);
83
+ const base = resolveTlonChannelConfig(cfg);
84
+
85
+ if (!base) {
86
+ return {
87
+ accountId: resolvedAccountId,
88
+ name: null,
89
+ enabled: false,
90
+ configured: false,
91
+ ship: null,
92
+ url: null,
93
+ code: null,
94
+ dangerouslyAllowPrivateNetwork: null,
95
+ groupChannels: [],
96
+ dmAllowlist: [],
97
+ groupInviteAllowlist: [],
98
+ autoDiscoverChannels: null,
99
+ showModelSignature: null,
100
+ autoAcceptDmInvites: null,
101
+ autoAcceptGroupInvites: null,
102
+ defaultAuthorizedShips: [],
103
+ ownerShip: null,
104
+ };
105
+ }
106
+
107
+ const merged = resolveMergedTlonAccountConfig(cfg, resolvedAccountId);
108
+ const ship = merged.ship ?? null;
109
+ const url = merged.url ?? null;
110
+ const code = merged.code ?? null;
111
+ const dangerouslyAllowPrivateNetwork = isPrivateNetworkOptInEnabled(merged)
112
+ ? true
113
+ : typeof merged.network?.dangerouslyAllowPrivateNetwork === "boolean"
114
+ ? merged.network.dangerouslyAllowPrivateNetwork
115
+ : hasLegacyFlatAllowPrivateNetworkAlias(merged) &&
116
+ typeof merged.allowPrivateNetwork === "boolean"
117
+ ? merged.allowPrivateNetwork
118
+ : null;
119
+ const groupChannels = merged.groupChannels ?? [];
120
+ const dmAllowlist = merged.dmAllowlist ?? [];
121
+ const groupInviteAllowlist = merged.groupInviteAllowlist ?? [];
122
+ const autoDiscoverChannels = merged.autoDiscoverChannels ?? null;
123
+ const showModelSignature = merged.showModelSignature ?? null;
124
+ const autoAcceptDmInvites = merged.autoAcceptDmInvites ?? null;
125
+ const autoAcceptGroupInvites = merged.autoAcceptGroupInvites ?? null;
126
+ const ownerShip = merged.ownerShip ?? null;
127
+ const defaultAuthorizedShips = merged.defaultAuthorizedShips ?? [];
128
+ const configured = Boolean(ship && url && code);
129
+
130
+ return {
131
+ accountId: resolvedAccountId,
132
+ name: merged.name ?? null,
133
+ enabled: merged.enabled !== false,
134
+ configured,
135
+ ship,
136
+ url,
137
+ code,
138
+ dangerouslyAllowPrivateNetwork,
139
+ groupChannels,
140
+ dmAllowlist,
141
+ groupInviteAllowlist,
142
+ autoDiscoverChannels,
143
+ showModelSignature,
144
+ autoAcceptDmInvites,
145
+ autoAcceptGroupInvites,
146
+ defaultAuthorizedShips,
147
+ ownerShip,
148
+ };
149
+ }
150
+
151
+ export function listTlonAccountIds(cfg: KlawConfig): string[] {
152
+ const base = resolveTlonChannelConfig(cfg);
153
+ if (!base) {
154
+ return [];
155
+ }
156
+ return listCombinedAccountIds({
157
+ configuredAccountIds: Object.keys(base.accounts ?? {}).map(normalizeAccountId),
158
+ implicitAccountId: base.ship ? DEFAULT_ACCOUNT_ID : undefined,
159
+ });
160
+ }
@@ -0,0 +1,45 @@
1
+ import { SsrFBlockedError } from "klaw/plugin-sdk/ssrf-runtime";
2
+ import type { LookupFn } from "klaw/plugin-sdk/ssrf-runtime";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { authenticate } from "./auth.js";
5
+
6
+ describe("tlon urbit auth ssrf", () => {
7
+ beforeEach(() => {
8
+ vi.unstubAllGlobals();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.unstubAllGlobals();
13
+ });
14
+
15
+ it("blocks private IPs by default", async () => {
16
+ const mockFetch = vi.fn();
17
+ vi.stubGlobal("fetch", mockFetch);
18
+
19
+ await expect(authenticate("http://127.0.0.1:8080", "code")).rejects.toBeInstanceOf(
20
+ SsrFBlockedError,
21
+ );
22
+ expect(mockFetch).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it("allows private IPs when allowPrivateNetwork is enabled", async () => {
26
+ const mockFetch = vi.fn().mockResolvedValue({
27
+ ok: true,
28
+ status: 200,
29
+ text: async () => "ok",
30
+ headers: new Headers({
31
+ "set-cookie": "urbauth-~zod=123; Path=/; HttpOnly",
32
+ }),
33
+ });
34
+ vi.stubGlobal("fetch", mockFetch);
35
+ const lookupFn = (async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn;
36
+
37
+ const cookie = await authenticate("http://127.0.0.1:8080", "code", {
38
+ ssrfPolicy: { allowPrivateNetwork: true },
39
+ lookupFn,
40
+ fetchImpl: mockFetch as typeof fetch,
41
+ });
42
+ expect(cookie).toContain("urbauth-~zod=123");
43
+ expect(mockFetch).toHaveBeenCalled();
44
+ });
45
+ });
@@ -0,0 +1,48 @@
1
+ import type { LookupFn, SsrFPolicy } from "klaw/plugin-sdk/ssrf-runtime";
2
+ import { UrbitAuthError } from "./errors.js";
3
+ import { urbitFetch } from "./fetch.js";
4
+
5
+ type UrbitAuthenticateOptions = {
6
+ ssrfPolicy?: SsrFPolicy;
7
+ lookupFn?: LookupFn;
8
+ fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
9
+ timeoutMs?: number;
10
+ };
11
+
12
+ export async function authenticate(
13
+ url: string,
14
+ code: string,
15
+ options: UrbitAuthenticateOptions = {},
16
+ ): Promise<string> {
17
+ const { response, release } = await urbitFetch({
18
+ baseUrl: url,
19
+ path: "/~/login",
20
+ init: {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
23
+ body: new URLSearchParams({ password: code }).toString(),
24
+ },
25
+ ssrfPolicy: options.ssrfPolicy,
26
+ lookupFn: options.lookupFn,
27
+ fetchImpl: options.fetchImpl,
28
+ timeoutMs: options.timeoutMs ?? 15_000,
29
+ maxRedirects: 3,
30
+ auditContext: "tlon-urbit-login",
31
+ });
32
+
33
+ try {
34
+ if (!response.ok) {
35
+ throw new UrbitAuthError("auth_failed", `Login failed with status ${response.status}`);
36
+ }
37
+
38
+ // Some Urbit setups require the response body to be read before cookie headers finalize.
39
+ await response.text().catch(() => {});
40
+ const cookie = response.headers.get("set-cookie");
41
+ if (!cookie) {
42
+ throw new UrbitAuthError("missing_cookie", "No authentication cookie received");
43
+ }
44
+ return cookie;
45
+ } finally {
46
+ await release();
47
+ }
48
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { validateUrbitBaseUrl } from "./base-url.js";
3
+
4
+ describe("validateUrbitBaseUrl", () => {
5
+ function expectValidBaseUrl(raw: string) {
6
+ const result = validateUrbitBaseUrl(raw);
7
+ expect(result.ok).toBe(true);
8
+ if (!result.ok) {
9
+ throw new Error(result.error);
10
+ }
11
+ return result;
12
+ }
13
+
14
+ it("adds https:// when scheme is missing and strips path/query fragments", () => {
15
+ const result = expectValidBaseUrl("example.com/foo?bar=baz");
16
+ expect(result.baseUrl).toBe("https://example.com");
17
+ expect(result.hostname).toBe("example.com");
18
+ });
19
+
20
+ it("rejects non-http schemes", () => {
21
+ const result = validateUrbitBaseUrl("file:///etc/passwd");
22
+ expect(result.ok).toBe(false);
23
+ if (result.ok) {
24
+ return;
25
+ }
26
+ expect(result.error).toContain("http:// or https://");
27
+ });
28
+
29
+ it("rejects embedded credentials", () => {
30
+ const result = validateUrbitBaseUrl("https://user:pass@example.com");
31
+ expect(result.ok).toBe(false);
32
+ if (result.ok) {
33
+ return;
34
+ }
35
+ expect(result.error).toContain("credentials");
36
+ });
37
+
38
+ it("normalizes a trailing dot in the hostname for origin construction", () => {
39
+ const result = expectValidBaseUrl("https://example.com./foo");
40
+ expect(result.baseUrl).toBe("https://example.com");
41
+ expect(result.hostname).toBe("example.com");
42
+ });
43
+
44
+ it("preserves port in the normalized origin", () => {
45
+ const result = expectValidBaseUrl("http://example.com:8080/~/login");
46
+ expect(result.baseUrl).toBe("http://example.com:8080");
47
+ });
48
+ });