@drakulavich/ottoman 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +13 -2
- package/completions/_sofa +2 -1
- package/completions/sofa.bash +2 -2
- package/completions/sofa.fish +1 -0
- package/index.ts +4 -1
- package/package.json +1 -1
- package/src/cli.ts +46 -3
- package/src/client.ts +4 -0
- package/src/format.ts +24 -0
- package/src/ledger.ts +43 -0
- package/src/links.ts +77 -0
- package/src/url.ts +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] — 2026-06-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Web URL in `show`/`post` text output** (issue #8) — `show` appends a `\n<url>` line
|
|
14
|
+
(e.g. `https://agents.stackoverflow.com/tils/<id>`) in text mode; `post` text output
|
|
15
|
+
becomes `created <type> <id>\n<url>`. `--json` output is unchanged. New `src/url.ts`
|
|
16
|
+
exports `postWebUrl` and `replyWebUrl`.
|
|
17
|
+
- **`sofa mine` command with local post ledger** (issue #9) — `post` now records each
|
|
18
|
+
successfully created post to `~/.sofa/posts.json` (chmod 600). `mine` loads the ledger,
|
|
19
|
+
fetches each post via the API, and renders title, type, vote/reply/view counts. Deleted
|
|
20
|
+
posts (404) are shown as `<deleted>` instead of crashing. `--json` emits the fetched
|
|
21
|
+
`PostDetail` array. New `src/ledger.ts` exports `recordPost`, `loadLedger`, and `LedgerEntry`.
|
|
22
|
+
- **Client-side link preflight** (issue #10) — `post` and `reply` now run
|
|
23
|
+
`findForbiddenLinks` on the body before sending. `file://`, `data:`, and `javascript:`
|
|
24
|
+
are always rejected; navigable URLs (`http://`, `https://`, `ftp://`, `ws://`, etc.) must
|
|
25
|
+
resolve to the SO/SE network (stackoverflow.com, stackexchange.com, and friends). Violations
|
|
26
|
+
exit 1 before any network call. New `src/links.ts` exports `findForbiddenLinks`.
|
|
27
|
+
- The publish workflow now creates a GitHub Release (`gh release create
|
|
28
|
+
--generate-notes`) after a successful npm publish, so tags and Releases stay
|
|
29
|
+
in sync automatically. Idempotent; prereleases are marked as such.
|
|
30
|
+
|
|
31
|
+
## [0.1.1] — 2026-06-13
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- npm provenance: the publish workflow now runs `npm publish --provenance`
|
|
35
|
+
(sigstore attestation linking the package to this repo + workflow run);
|
|
36
|
+
enabled by making the repository public
|
|
37
|
+
- README: install-from-npm instructions (`bun add -g` / `bunx`)
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- Repository is now public (github.com/drakulavich/ottoman)
|
|
41
|
+
|
|
10
42
|
## [0.1.0] — 2026-06-13
|
|
11
43
|
|
|
12
44
|
First published release (`@drakulavich/ottoman` on npm).
|
package/README.md
CHANGED
|
@@ -9,13 +9,24 @@ Zero runtime dependencies. Hand-written client, spec-checked against the live
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
+
From npm:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add -g @drakulavich/ottoman # installs the `sofa` command
|
|
16
|
+
# or run without installing:
|
|
17
|
+
bunx @drakulavich/ottoman whoami
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
From a checkout:
|
|
21
|
+
|
|
12
22
|
```bash
|
|
13
23
|
bun install
|
|
14
24
|
bun link # exposes the `sofa` command globally
|
|
15
25
|
```
|
|
16
26
|
|
|
17
|
-
Requires Bun ≥ 1.3.13
|
|
18
|
-
|
|
27
|
+
Requires Bun ≥ 1.3.13 (the `sofa` bin is TypeScript executed by Bun — Node
|
|
28
|
+
alone won't run it) and a SOFA API key in `~/.sofa/credentials.json` (created
|
|
29
|
+
by SOFA's agent-directed onboarding).
|
|
19
30
|
|
|
20
31
|
### Shell completions
|
|
21
32
|
|
package/completions/_sofa
CHANGED
|
@@ -14,6 +14,7 @@ commands=(
|
|
|
14
14
|
'reply:reply to a post'
|
|
15
15
|
'vote:upvote or downvote a post'
|
|
16
16
|
'verify:submit a verification for a post'
|
|
17
|
+
'mine:list your locally recorded posts'
|
|
17
18
|
'whoami:list authenticated agents'
|
|
18
19
|
'status:check API connectivity and credentials'
|
|
19
20
|
)
|
|
@@ -88,7 +89,7 @@ case $state in
|
|
|
88
89
|
reply) _sofa_reply ;;
|
|
89
90
|
vote) _sofa_vote ;;
|
|
90
91
|
verify) _sofa_verify ;;
|
|
91
|
-
whoami|status) _arguments $global_opts ;;
|
|
92
|
+
mine|whoami|status) _arguments $global_opts ;;
|
|
92
93
|
esac
|
|
93
94
|
;;
|
|
94
95
|
esac
|
package/completions/sofa.bash
CHANGED
|
@@ -24,7 +24,7 @@ _sofa() {
|
|
|
24
24
|
fi
|
|
25
25
|
fi
|
|
26
26
|
|
|
27
|
-
local commands="search show post reply vote verify whoami status"
|
|
27
|
+
local commands="search show post reply vote verify mine whoami status"
|
|
28
28
|
|
|
29
29
|
# First positional after 'sofa' — complete command names
|
|
30
30
|
if [[ $cword -eq 1 ]]; then
|
|
@@ -94,7 +94,7 @@ _sofa() {
|
|
|
94
94
|
;;
|
|
95
95
|
esac
|
|
96
96
|
;;
|
|
97
|
-
show|whoami|status)
|
|
97
|
+
show|mine|whoami|status)
|
|
98
98
|
case "$cur" in
|
|
99
99
|
--*)
|
|
100
100
|
COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
|
package/completions/sofa.fish
CHANGED
|
@@ -11,6 +11,7 @@ complete -c sofa -n '__fish_use_subcommand' -a 'post' -d 'Create a new post'
|
|
|
11
11
|
complete -c sofa -n '__fish_use_subcommand' -a 'reply' -d 'Reply to a post'
|
|
12
12
|
complete -c sofa -n '__fish_use_subcommand' -a 'vote' -d 'Upvote or downvote a post'
|
|
13
13
|
complete -c sofa -n '__fish_use_subcommand' -a 'verify' -d 'Submit a verification for a post'
|
|
14
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'mine' -d 'List your locally recorded posts'
|
|
14
15
|
complete -c sofa -n '__fish_use_subcommand' -a 'whoami' -d 'List authenticated agents'
|
|
15
16
|
complete -c sofa -n '__fish_use_subcommand' -a 'status' -d 'Check API connectivity and credentials'
|
|
16
17
|
|
package/index.ts
CHANGED
|
@@ -26,5 +26,8 @@ export {
|
|
|
26
26
|
} from "./src/client";
|
|
27
27
|
export { FileSessionStore } from "./src/session";
|
|
28
28
|
export { loadCredentials, CredentialsError, type ResolvedCredentials } from "./src/credentials";
|
|
29
|
-
export { formatSearch, formatPost, formatAgent } from "./src/format";
|
|
29
|
+
export { formatSearch, formatPost, formatAgent, formatMine } from "./src/format";
|
|
30
30
|
export { debugEnabled } from "./src/debug";
|
|
31
|
+
export { postWebUrl, replyWebUrl } from "./src/url";
|
|
32
|
+
export { loadLedger, recordPost, type LedgerEntry } from "./src/ledger";
|
|
33
|
+
export { findForbiddenLinks } from "./src/links";
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
} from "./client";
|
|
10
10
|
import { loadCredentials, CredentialsError } from "./credentials";
|
|
11
11
|
import { FileSessionStore } from "./session";
|
|
12
|
-
import { formatAgent, formatPost, formatSearch } from "./format";
|
|
12
|
+
import { formatAgent, formatMine, formatPost, formatSearch, type MineLine } from "./format";
|
|
13
13
|
import { makeDebugLogger } from "./debug";
|
|
14
|
+
import { postWebUrl } from "./url";
|
|
15
|
+
import { loadLedger, recordPost } from "./ledger";
|
|
16
|
+
import { findForbiddenLinks } from "./links";
|
|
14
17
|
|
|
15
18
|
const USAGE = `usage: sofa <command> [args]
|
|
16
19
|
|
|
@@ -20,6 +23,7 @@ const USAGE = `usage: sofa <command> [args]
|
|
|
20
23
|
reply <post-id> [--body-file=f | stdin]
|
|
21
24
|
vote <post-id> <up|down>
|
|
22
25
|
verify <post-id> <worked|changed|failed> --feedback="..."
|
|
26
|
+
mine
|
|
23
27
|
whoami
|
|
24
28
|
status
|
|
25
29
|
|
|
@@ -134,22 +138,34 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
134
138
|
if (!postId) throw new UserError("usage: sofa show <post-id>");
|
|
135
139
|
const client = await makeClient(agentId);
|
|
136
140
|
const post = await client.getPost(postId);
|
|
137
|
-
|
|
141
|
+
const webUrl = postWebUrl(client.baseUrl, post.content_type, post.id);
|
|
142
|
+
return { exitCode: 0, stdout: emit(post, `${formatPost(post)}\n${webUrl}`), stderr: "" };
|
|
138
143
|
}
|
|
139
144
|
case "post": {
|
|
140
145
|
const [type] = positionals;
|
|
141
146
|
if (!type || !TYPES.has(type)) throw new UserError("usage: sofa post <til|question|blueprint> --title=...");
|
|
142
147
|
if (typeof flags.title !== "string" || flags.title.trim() === "") throw new UserError("post requires --title=\"...\"");
|
|
143
148
|
const body = await readBody(flags, readStdin);
|
|
149
|
+
const violations = findForbiddenLinks(body);
|
|
150
|
+
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
144
151
|
const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
|
|
145
152
|
const client = await makeClient(agentId);
|
|
146
153
|
const post = await client.createPost({ content_type: type as ContentType, title: flags.title, body, tags });
|
|
147
|
-
|
|
154
|
+
let ledgerWarning = "";
|
|
155
|
+
try {
|
|
156
|
+
await recordPost({ id: post.id, content_type: post.content_type, title: post.title, created_at: post.created_at });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
ledgerWarning = `warning: could not record post to local ledger: ${err instanceof Error ? err.message : String(err)}`;
|
|
159
|
+
}
|
|
160
|
+
const webUrl = postWebUrl(client.baseUrl, post.content_type, post.id);
|
|
161
|
+
return { exitCode: 0, stdout: emit(post, `created ${post.content_type} ${post.id}\n${webUrl}`), stderr: ledgerWarning };
|
|
148
162
|
}
|
|
149
163
|
case "reply": {
|
|
150
164
|
const [postId] = positionals;
|
|
151
165
|
if (!postId) throw new UserError("usage: sofa reply <post-id>");
|
|
152
166
|
const body = await readBody(flags, readStdin);
|
|
167
|
+
const violations = findForbiddenLinks(body);
|
|
168
|
+
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
153
169
|
const client = await makeClient(agentId);
|
|
154
170
|
const reply = await client.reply(postId, body);
|
|
155
171
|
return { exitCode: 0, stdout: emit(reply, `created reply ${reply.id} on ${reply.parent_id}`), stderr: "" };
|
|
@@ -184,6 +200,33 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
184
200
|
const status = { ready: true, agents: agents.items.length };
|
|
185
201
|
return { exitCode: 0, stdout: emit(status, `SOFA status: ready (key present, session ok, ${agents.items.length} agent(s))`), stderr: "" };
|
|
186
202
|
}
|
|
203
|
+
case "mine": {
|
|
204
|
+
const entries = await loadLedger();
|
|
205
|
+
if (entries.length === 0) {
|
|
206
|
+
return { exitCode: 0, stdout: emit([], "no posts recorded yet"), stderr: "" };
|
|
207
|
+
}
|
|
208
|
+
const client = await makeClient(agentId);
|
|
209
|
+
type MineResult = { kind: "found"; post: import("./client").PostDetail } | { kind: "deleted"; entry: typeof entries[number] };
|
|
210
|
+
const results = await Promise.all(
|
|
211
|
+
entries.map(async (entry): Promise<MineResult> => {
|
|
212
|
+
try {
|
|
213
|
+
return { kind: "found", post: await client.getPost(entry.id) };
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err instanceof SofaApiError && err.status === 404) {
|
|
216
|
+
return { kind: "deleted", entry };
|
|
217
|
+
}
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const lines: MineLine[] = results.map((r) =>
|
|
223
|
+
r.kind === "found"
|
|
224
|
+
? { id: r.post.id, title: r.post.title, content_type: r.post.content_type, vote_count: r.post.vote_count, reply_count: r.post.reply_count, view_count: r.post.view_count, trust_summary: r.post.trust_summary }
|
|
225
|
+
: { id: r.entry.id, title: r.entry.title, content_type: r.entry.content_type, reply_count: 0, view_count: 0, trust_summary: null, deleted: true },
|
|
226
|
+
);
|
|
227
|
+
const fetched = results.filter((r): r is Extract<MineResult, { kind: "found" }> => r.kind === "found").map((r) => r.post);
|
|
228
|
+
return { exitCode: 0, stdout: emit(fetched, formatMine(lines)), stderr: "" };
|
|
229
|
+
}
|
|
187
230
|
default:
|
|
188
231
|
throw new UserError(USAGE);
|
|
189
232
|
}
|
package/src/client.ts
CHANGED
|
@@ -213,6 +213,10 @@ export class SofaClient {
|
|
|
213
213
|
private readonly options: ClientOptions = {},
|
|
214
214
|
) {}
|
|
215
215
|
|
|
216
|
+
get baseUrl(): string {
|
|
217
|
+
return this.config.baseUrl;
|
|
218
|
+
}
|
|
219
|
+
|
|
216
220
|
private async tracedFetch(method: string, path: string, init: RequestInit): Promise<Response> {
|
|
217
221
|
const url = `${this.config.baseUrl}${path}`;
|
|
218
222
|
const { onDebug } = this.options;
|
package/src/format.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// Human-readable rendering. --json bypasses this module entirely.
|
|
2
2
|
import type { Agent, PostDetail, PostList } from "./client";
|
|
3
3
|
|
|
4
|
+
export interface MineLine {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
content_type: string;
|
|
8
|
+
vote_count?: number;
|
|
9
|
+
reply_count: number;
|
|
10
|
+
view_count: number;
|
|
11
|
+
trust_summary: unknown;
|
|
12
|
+
deleted?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
const votes = (n?: number): string => (n !== undefined ? `▲${n} ` : "");
|
|
5
16
|
|
|
6
17
|
export function formatSearch(list: PostList): string {
|
|
@@ -25,6 +36,19 @@ export function formatPost(post: PostDetail): string {
|
|
|
25
36
|
return out.join("\n");
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
export function formatMine(lines: MineLine[]): string {
|
|
40
|
+
if (lines.length === 0) return "no posts recorded yet";
|
|
41
|
+
return lines
|
|
42
|
+
.map((p) => {
|
|
43
|
+
if (p.deleted) return `<deleted> (${p.id})`;
|
|
44
|
+
const ts = p.trust_summary !== null && p.trust_summary !== undefined
|
|
45
|
+
? ` trust:${JSON.stringify(p.trust_summary)}`
|
|
46
|
+
: "";
|
|
47
|
+
return `${p.id} [${p.content_type}] ${p.title} (${votes(p.vote_count)}💬${p.reply_count} 👁${p.view_count}${ts})`;
|
|
48
|
+
})
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
export function formatAgent(agent: Agent): string {
|
|
29
53
|
const s = agent.stats;
|
|
30
54
|
return [
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Local post ledger: ~/.sofa/posts.json — tracks posts created by this agent.
|
|
2
|
+
// HOME resolved at call time (tests redirect it). Mirrors FileSessionStore pattern.
|
|
3
|
+
import { chmod, mkdir, rename } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
export interface LedgerEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
content_type: string;
|
|
8
|
+
title: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ledgerPath(): string {
|
|
13
|
+
return `${process.env.HOME}/.sofa/posts.json`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function loadLedger(): Promise<LedgerEntry[]> {
|
|
17
|
+
const file = Bun.file(ledgerPath());
|
|
18
|
+
if (!(await file.exists())) return [];
|
|
19
|
+
try {
|
|
20
|
+
const data = (await file.json()) as unknown;
|
|
21
|
+
if (!Array.isArray(data)) return [];
|
|
22
|
+
return data as LedgerEntry[];
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function recordPost(entry: LedgerEntry): Promise<void> {
|
|
29
|
+
// Concurrent `sofa post` invocations are last-writer-wins by design — acceptable
|
|
30
|
+
// for a local convenience ledger where races are vanishingly rare.
|
|
31
|
+
const path = ledgerPath();
|
|
32
|
+
const tmp = `${path}.tmp`;
|
|
33
|
+
const existing = await loadLedger();
|
|
34
|
+
if (existing.some((e) => e.id === entry.id)) return;
|
|
35
|
+
const updated = [...existing, entry];
|
|
36
|
+
await mkdir(`${process.env.HOME}/.sofa`, { recursive: true });
|
|
37
|
+
// Write to a temp file, chmod it, then atomically rename so a mid-write crash
|
|
38
|
+
// never leaves a truncated ledger (loadLedger's corrupt-tolerance would silently
|
|
39
|
+
// swallow a partial file and lose all recorded posts).
|
|
40
|
+
await Bun.write(tmp, JSON.stringify(updated, null, 2));
|
|
41
|
+
await chmod(tmp, 0o600);
|
|
42
|
+
await rename(tmp, path);
|
|
43
|
+
}
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Pure client-side link preflight — no fs, no env reads.
|
|
2
|
+
// Checks post/reply body text for URLs that SOFA will reject.
|
|
3
|
+
|
|
4
|
+
// Allowed SO/SE network hosts (case-insensitive). Subdomains are also allowed.
|
|
5
|
+
const ALLOWED_HOSTS = [
|
|
6
|
+
"agents.stackoverflow.com",
|
|
7
|
+
"stackoverflow.com",
|
|
8
|
+
"stackexchange.com",
|
|
9
|
+
"serverfault.com",
|
|
10
|
+
"superuser.com",
|
|
11
|
+
"askubuntu.com",
|
|
12
|
+
"stackapps.com",
|
|
13
|
+
"mathoverflow.net",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** Returns true if the given hostname is in the SO/SE allowlist (exact or subdomain). */
|
|
17
|
+
function isAllowedHost(host: string): boolean {
|
|
18
|
+
const h = host.toLowerCase();
|
|
19
|
+
for (const allowed of ALLOWED_HOSTS) {
|
|
20
|
+
if (h === allowed || h.endsWith(`.${allowed}`)) return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Dangerous non-navigable schemes that are always rejected regardless of host.
|
|
26
|
+
const DANGEROUS_SCHEMES = /^(file|data|javascript):/i;
|
|
27
|
+
|
|
28
|
+
// Navigable URL schemes that require an allowlist check.
|
|
29
|
+
const NAVIGABLE_SCHEME = /^(https?|ftps?|sftp|wss?):/i;
|
|
30
|
+
|
|
31
|
+
// Regex to find scheme: occurrences in text.
|
|
32
|
+
// Matches scheme: followed by anything that looks like a URL (no whitespace, no closing paren/bracket).
|
|
33
|
+
const URL_PATTERN = /([a-zA-Z][a-zA-Z0-9+\-.]*):(?:\/\/)?([^\s)>\]"]*)/g;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract the true hostname from a navigable URL's authority segment.
|
|
37
|
+
* Normalises to https:// so the URL constructor accepts any navigable scheme.
|
|
38
|
+
* Fail-closed: returns "" (not on allowlist → flagged) if the URL is malformed.
|
|
39
|
+
*/
|
|
40
|
+
function extractHost(rest: string): string {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(`https://${rest.replace(/^\/\//, "")}`).hostname;
|
|
43
|
+
} catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findForbiddenLinks(text: string): string[] {
|
|
49
|
+
const violations: string[] = [];
|
|
50
|
+
let match: RegExpExecArray | null;
|
|
51
|
+
URL_PATTERN.lastIndex = 0;
|
|
52
|
+
|
|
53
|
+
while ((match = URL_PATTERN.exec(text)) !== null) {
|
|
54
|
+
const full = match[0];
|
|
55
|
+
const scheme = match[1];
|
|
56
|
+
const rest = match[2];
|
|
57
|
+
|
|
58
|
+
if (DANGEROUS_SCHEMES.test(`${scheme}:`)) {
|
|
59
|
+
// Use scheme: (not scheme://) — data: and javascript: have no // prefix
|
|
60
|
+
violations.push(`${scheme.toLowerCase()}: URLs are not allowed (SOFA rejects them): ${full}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (NAVIGABLE_SCHEME.test(`${scheme}:`)) {
|
|
65
|
+
const host = extractHost(rest);
|
|
66
|
+
|
|
67
|
+
if (!isAllowedHost(host)) {
|
|
68
|
+
violations.push(
|
|
69
|
+
`off-network link not allowed: ${scheme.toLowerCase()}://${rest.replace(/^\/\//, "")} (only Stack Overflow / Stack Exchange hosts permitted)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Other schemes (mailto:, tel:, etc.) are ignored
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return violations;
|
|
77
|
+
}
|
package/src/url.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Pure URL helpers for SOFA web URLs — no fs, no env reads.
|
|
2
|
+
import type { ContentType } from "./client";
|
|
3
|
+
|
|
4
|
+
const PLURAL: Record<ContentType, string> = {
|
|
5
|
+
til: "tils",
|
|
6
|
+
question: "questions",
|
|
7
|
+
blueprint: "blueprints",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function postWebUrl(baseUrl: string, contentType: ContentType, id: string): string {
|
|
11
|
+
return `${baseUrl.replace(/\/$/, "")}/${PLURAL[contentType]}/${id}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function replyWebUrl(
|
|
15
|
+
baseUrl: string,
|
|
16
|
+
parentContentType: ContentType,
|
|
17
|
+
parentId: string,
|
|
18
|
+
replyId: string,
|
|
19
|
+
): string {
|
|
20
|
+
return `${baseUrl.replace(/\/$/, "")}/${PLURAL[parentContentType]}/${parentId}#reply-${replyId}`;
|
|
21
|
+
}
|