@snagset/server 0.0.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/README.md +83 -0
- package/dist/app.js +453 -0
- package/dist/app.js.map +1 -0
- package/dist/bin.js +951 -0
- package/dist/bin.js.map +1 -0
- package/dist/boot.js +321 -0
- package/dist/boot.js.map +1 -0
- package/dist/config.generated.js +116 -0
- package/dist/config.generated.js.map +1 -0
- package/dist/config.js +180 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.js +26 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db/connect.js +49 -0
- package/dist/db/connect.js.map +1 -0
- package/dist/db/migrate.js +210 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/migrations/0001_init.sql +1060 -0
- package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
- package/dist/db/migrations/0003_email_provider.sql +35 -0
- package/dist/db/migrations/0004_unsubscribe.sql +24 -0
- package/dist/db/rls.js +58 -0
- package/dist/db/rls.js.map +1 -0
- package/dist/jobs/index.js +171 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/reaper.js +182 -0
- package/dist/jobs/reaper.js.map +1 -0
- package/dist/keys.js +77 -0
- package/dist/keys.js.map +1 -0
- package/dist/mail/index.js +75 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/mail/mailer.js +27 -0
- package/dist/mail/mailer.js.map +1 -0
- package/dist/mail/notify.js +405 -0
- package/dist/mail/notify.js.map +1 -0
- package/dist/mail/resend.js +95 -0
- package/dist/mail/resend.js.map +1 -0
- package/dist/mail/smtp.js +315 -0
- package/dist/mail/smtp.js.map +1 -0
- package/dist/mail/unsubscribe.js +134 -0
- package/dist/mail/unsubscribe.js.map +1 -0
- package/dist/projectCache.js +50 -0
- package/dist/projectCache.js.map +1 -0
- package/dist/projects.js +101 -0
- package/dist/projects.js.map +1 -0
- package/dist/routes/attachments.js +159 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.js +55 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/commentEdit.js +201 -0
- package/dist/routes/commentEdit.js.map +1 -0
- package/dist/routes/comments.js +313 -0
- package/dist/routes/comments.js.map +1 -0
- package/dist/routes/cors.js +174 -0
- package/dist/routes/cors.js.map +1 -0
- package/dist/routes/jobs.js +56 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/mcpAuth.js +187 -0
- package/dist/routes/mcpAuth.js.map +1 -0
- package/dist/routes/mcpToken.js +94 -0
- package/dist/routes/mcpToken.js.map +1 -0
- package/dist/routes/owner.js +288 -0
- package/dist/routes/owner.js.map +1 -0
- package/dist/routes/reviewLinks.js +169 -0
- package/dist/routes/reviewLinks.js.map +1 -0
- package/dist/routes/rounds.js +137 -0
- package/dist/routes/rounds.js.map +1 -0
- package/dist/routes/sessions.js +349 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/sites.js +192 -0
- package/dist/routes/sites.js.map +1 -0
- package/dist/routes/threads.js +1080 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/widget.js +132 -0
- package/dist/routes/widget.js.map +1 -0
- package/dist/serverless.js +97 -0
- package/dist/serverless.js.map +1 -0
- package/dist/session.js +152 -0
- package/dist/session.js.map +1 -0
- package/dist/storage/bucket.js +116 -0
- package/dist/storage/bucket.js.map +1 -0
- package/dist/storage/settings.js +132 -0
- package/dist/storage/settings.js.map +1 -0
- package/dist/vendor/adapters/index.js +1 -0
- package/dist/vendor/adapters/outbound/fence.js +44 -0
- package/dist/vendor/adapters/outbound/sanitise.js +233 -0
- package/dist/vendor/types/index.js +4 -0
- package/dist/vendor/types/names.js +254 -0
- package/dist/vendor/widget/object.js +38 -0
- package/dist/vendor/widget/predicates.js +377 -0
- package/package.json +53 -0
- package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
- package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
- package/static/dashboard/index.html +14 -0
- package/static/widget/snag.anchor-ext.js +1 -0
- package/static/widget/snag.anchor-fuzzy.js +1 -0
- package/static/widget/snag.capture.js +4 -0
- package/static/widget/snag.crumbs.js +4 -0
- package/static/widget/snag.dock.js +5 -0
- package/static/widget/snag.js +5 -0
- package/static/widget/snag.panels.js +2 -0
- package/static/widget/snag.runtime.js +2 -0
- package/static/widget/snag.screenshot.js +1 -0
- package/static/widget/snag.text.js +2 -0
- package/vercel.json +6 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
8
|
+
import { ID_PREFIX } from "./vendor/types/names.js";
|
|
9
|
+
import { connect } from "./db/connect.js";
|
|
10
|
+
import { loadConfig, ConfigError } from "./config.js";
|
|
11
|
+
import { migrate, MigrationError } from "./db/migrate.js";
|
|
12
|
+
import { boot, bootstrapFromEnv, claimBanner, mintClaim, resolveSecretKey } from "./boot.js";
|
|
13
|
+
import { REGISTRY } from "./config.generated.js";
|
|
14
|
+
import { createProject } from "./projects.js";
|
|
15
|
+
import { createApp } from "./app.js";
|
|
16
|
+
/**
|
|
17
|
+
* `npx snagset` — 08 §3.3.
|
|
18
|
+
*
|
|
19
|
+
* *"with `DATABASE_URL` set. The bin migrates its own schema on boot, serves
|
|
20
|
+
* API + widget assets + dashboard on one port, and ticks its own jobs
|
|
21
|
+
* in-process. The claim URL is printed to the logs."*
|
|
22
|
+
*
|
|
23
|
+
* The order below is the order the spec gives, and each step's failure has its
|
|
24
|
+
* own exit code because a boot script has to tell them apart: bad config (2),
|
|
25
|
+
* unreachable database (3), refused migration (4).
|
|
26
|
+
*/
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8"));
|
|
29
|
+
/** The build's commit, for `GET /api/v1/meta`. AGPL-3.0 §13 needs it to point
|
|
30
|
+
* at the source that corresponds to THIS binary, so an unknown commit is
|
|
31
|
+
* reported as unknown rather than guessed. */
|
|
32
|
+
function describeBuild() {
|
|
33
|
+
try {
|
|
34
|
+
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
35
|
+
cwd: HERE,
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
38
|
+
}).trim();
|
|
39
|
+
const dirty = execFileSync("git", ["status", "--porcelain"], {
|
|
40
|
+
cwd: HERE,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
43
|
+
}).trim().length > 0;
|
|
44
|
+
return { commit, modified: dirty };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not a git checkout — an installed package. Neither is a failure.
|
|
48
|
+
return { commit: "unknown", modified: false };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load `.env` files before anything reads configuration.
|
|
53
|
+
*
|
|
54
|
+
* The server had none of this and read `process.env` only, which is correct
|
|
55
|
+
* for a container and wrong for every other way people actually run it: a
|
|
56
|
+
* `.env` beside the package is the near-universal convention, and an operator
|
|
57
|
+
* who writes one and sees no effect concludes the variable does not work
|
|
58
|
+
* rather than that the file was never read. Found exactly that way — a
|
|
59
|
+
* populated `.env.local` that did nothing at all.
|
|
60
|
+
*
|
|
61
|
+
* `process.loadEnvFile` is Node's own (24+), so this costs no dependency in a
|
|
62
|
+
* package whose pitch is `npx snagset`. Its semantics are the ones we want and
|
|
63
|
+
* were checked rather than assumed: **a variable already in the real
|
|
64
|
+
* environment is left alone**, so a platform's injected `DATABASE_URL` always
|
|
65
|
+
* beats a stale file someone forgot to delete.
|
|
66
|
+
*
|
|
67
|
+
* Order is MOST-SPECIFIC FIRST, and that is the half worth stating: because
|
|
68
|
+
* each pass only fills what is still missing, the first file to set a variable
|
|
69
|
+
* is the one that wins. Listing `.env` before `.env.local` therefore inverts
|
|
70
|
+
* the convention every other tool follows — `.env.local` is the personal
|
|
71
|
+
* override and has to be read first to be an override at all. (Written the
|
|
72
|
+
* wrong way round first, and caught by reading it back.)
|
|
73
|
+
*
|
|
74
|
+
* It also WALKS UP, which the first version did not, and that omission was
|
|
75
|
+
* found the moment it mattered: in a monorepo the env file lives at the root,
|
|
76
|
+
* `snagset …` gets run from inside `apps/server`, and the loader looked in the
|
|
77
|
+
* package and the working directory and nowhere else — so the file the
|
|
78
|
+
* operator had just written was invisible from one directory and visible from
|
|
79
|
+
* another. The walk stops at the first directory holding a `.git`, so it can
|
|
80
|
+
* never wander into a home directory or `/` and pick up a stranger's `.env`.
|
|
81
|
+
*
|
|
82
|
+
* A missing file is the normal case and is silent; an unreadable or malformed
|
|
83
|
+
* one is worth a word, because that is a typo somebody is waiting on.
|
|
84
|
+
*/
|
|
85
|
+
function loadEnvFiles() {
|
|
86
|
+
const dirs = [];
|
|
87
|
+
const walkUp = (from) => {
|
|
88
|
+
let dir = resolve(from);
|
|
89
|
+
for (let i = 0; i < 8; i++) {
|
|
90
|
+
if (!dirs.includes(dir))
|
|
91
|
+
dirs.push(dir);
|
|
92
|
+
// Inclusive: the repo root itself is searched, then the walk stops.
|
|
93
|
+
if (existsSync(join(dir, ".git")))
|
|
94
|
+
break;
|
|
95
|
+
const up = dirname(dir);
|
|
96
|
+
if (up === dir)
|
|
97
|
+
break;
|
|
98
|
+
dir = up;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
walkUp(process.cwd());
|
|
102
|
+
walkUp(join(HERE, ".."));
|
|
103
|
+
for (const dir of dirs) {
|
|
104
|
+
for (const name of [".env.local", ".env"]) {
|
|
105
|
+
const file = join(dir, name);
|
|
106
|
+
if (!existsSync(file))
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
process.loadEnvFile(file);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
console.error(`✗ could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function main() {
|
|
118
|
+
loadEnvFiles();
|
|
119
|
+
/**
|
|
120
|
+
* `env` — print the registry.
|
|
121
|
+
*
|
|
122
|
+
* FIRST, before `loadConfig` and before the database, and both of those
|
|
123
|
+
* orderings are the point.
|
|
124
|
+
*
|
|
125
|
+
* Before `loadConfig`, because this is the command you reach for when the
|
|
126
|
+
* config is wrong: a mistyped variable makes `loadConfig` throw, and a
|
|
127
|
+
* "run `snagset env`" suggestion inside that error would be a catch-22 if
|
|
128
|
+
* `env` itself had to load the config first.
|
|
129
|
+
*
|
|
130
|
+
* Before the database, because it answers a question about this BUILD rather
|
|
131
|
+
* than about an install — `snagset env` should work on a laptop with nothing
|
|
132
|
+
* provisioned, which is exactly when somebody is deciding what to provision.
|
|
133
|
+
*
|
|
134
|
+
* The pointer this replaces was `specs/08-deployment.md §4`. True, and
|
|
135
|
+
* useless to the operator it was written for: the npm tarball has no `specs/`
|
|
136
|
+
* directory, so the error's one next step was a file they could not open.
|
|
137
|
+
*/
|
|
138
|
+
if (process.argv[2] === "env") {
|
|
139
|
+
const filter = (process.argv[3] ?? "").toUpperCase();
|
|
140
|
+
let section = "";
|
|
141
|
+
for (const row of REGISTRY) {
|
|
142
|
+
if (filter && !row.name.includes(filter))
|
|
143
|
+
continue;
|
|
144
|
+
if (row.section !== section) {
|
|
145
|
+
section = row.section;
|
|
146
|
+
console.log(`\n── §${section} ${"─".repeat(Math.max(0, 60 - section.length))}`);
|
|
147
|
+
}
|
|
148
|
+
const set = process.env[row.name] !== undefined && process.env[row.name] !== "";
|
|
149
|
+
// A value is never printed — `secret: true` is in the registry precisely
|
|
150
|
+
// so a support paste of this output does not carry an API key.
|
|
151
|
+
const state = set
|
|
152
|
+
? row.secret
|
|
153
|
+
? "set (hidden)"
|
|
154
|
+
: `set — ${process.env[row.name]}`
|
|
155
|
+
: row.default !== null
|
|
156
|
+
? `default ${row.default}`
|
|
157
|
+
: row.required
|
|
158
|
+
? "REQUIRED, not set"
|
|
159
|
+
: "unset";
|
|
160
|
+
console.log(` ${row.name.padEnd(44)} ${state}`);
|
|
161
|
+
}
|
|
162
|
+
console.log("\nOnly DATABASE_URL is required. Everything else has a working default.");
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
const cfg = loadConfig(process.env);
|
|
166
|
+
const command = process.argv[2] ?? "serve";
|
|
167
|
+
if (![
|
|
168
|
+
"serve", "bootstrap", "migrate", "allow-origin", "origins", "projects",
|
|
169
|
+
"storage", "passwd", "link", "mcp-token", "env",
|
|
170
|
+
].includes(command)) {
|
|
171
|
+
console.error(`✗ unknown command "${command}" — try: serve | bootstrap | migrate | env | projects | origins | allow-origin | storage | passwd | link | mcp-token`);
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
const sql = connect(cfg.required("DATABASE_URL"), { max: cfg.int("SNAGSET_DATABASE_POOL_MAX") });
|
|
175
|
+
// §3.4 step 1 — migrations first, under the advisory lock. Nothing else can
|
|
176
|
+
// be trusted before the schema is the one this build expects.
|
|
177
|
+
await migrate(sql);
|
|
178
|
+
if (command === "migrate") {
|
|
179
|
+
await sql.end({ timeout: 5 });
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* `passwd` — set an owner's dashboard password from the machine that owns
|
|
184
|
+
* the database.
|
|
185
|
+
*
|
|
186
|
+
* There is no password-reset email on a fresh `npx snagset`: SMTP is
|
|
187
|
+
* unconfigured (LOCK L5 makes `DATABASE_URL` the only required config), so
|
|
188
|
+
* "forgot password" has no channel to arrive on. What the operator DOES have
|
|
189
|
+
* is a shell on the box and the connection string — which is strictly more
|
|
190
|
+
* authority than any reset link would grant — so that is the recovery path,
|
|
191
|
+
* and it is the same shape as `storage connect`: the secret comes in on
|
|
192
|
+
* stdin, because argv is visible in `ps` and lands in shell history.
|
|
193
|
+
*
|
|
194
|
+
* printf '%s' "$NEW" | snagset passwd owner@example.com -
|
|
195
|
+
*
|
|
196
|
+
* Every existing session for that user is revoked, which is what a password
|
|
197
|
+
* change has to mean: if the reason for changing it is that somebody else
|
|
198
|
+
* had it, leaving their cookie working changes nothing.
|
|
199
|
+
*/
|
|
200
|
+
if (command === "passwd") {
|
|
201
|
+
const { hashPassword } = await import("./routes/owner.js");
|
|
202
|
+
const email = (process.argv[3] ?? "").trim().toLowerCase();
|
|
203
|
+
const arg = process.argv[4] ?? "-";
|
|
204
|
+
if (!email) {
|
|
205
|
+
console.error("✗ usage: snagset passwd <email> [password|-]");
|
|
206
|
+
console.error(" `-` (the default) reads it from stdin, keeping it out of ps and shell history:");
|
|
207
|
+
console.error(" printf '%s' \"$NEW_PASSWORD\" | snagset passwd owner@example.com");
|
|
208
|
+
await sql.end({ timeout: 5 });
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
const password = arg === "-"
|
|
212
|
+
? await new Promise((resolve) => {
|
|
213
|
+
let buf = "";
|
|
214
|
+
process.stdin.setEncoding("utf8");
|
|
215
|
+
process.stdin.on("data", (d) => (buf += d));
|
|
216
|
+
process.stdin.on("end", () => resolve(buf.trim()));
|
|
217
|
+
})
|
|
218
|
+
: arg;
|
|
219
|
+
// 05 §4.7: minimum 12, no composition rules. A rule that demands a symbol
|
|
220
|
+
// buys nothing a longer passphrase does not buy more of.
|
|
221
|
+
if (password.length < 12) {
|
|
222
|
+
console.error(`✗ that password is ${password.length} characters; the minimum is 12.`);
|
|
223
|
+
console.error(" No composition rules — length is what matters. A short phrase of real words is fine.");
|
|
224
|
+
await sql.end({ timeout: 5 });
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
const rows = await sql `
|
|
228
|
+
UPDATE users SET password_hash = ${hashPassword(password)}, updated_at = now()
|
|
229
|
+
WHERE email = ${email} AND deleted_at IS NULL
|
|
230
|
+
RETURNING id
|
|
231
|
+
`;
|
|
232
|
+
if (!rows.length) {
|
|
233
|
+
// Named plainly. This is a local admin command run by somebody holding
|
|
234
|
+
// the database credentials; the enumeration reticence the login route
|
|
235
|
+
// owes an anonymous caller would only be a puzzle here.
|
|
236
|
+
console.error(`✗ no account with the email ${email}.`);
|
|
237
|
+
const known = await sql `
|
|
238
|
+
SELECT email FROM users WHERE deleted_at IS NULL ORDER BY created_at LIMIT 10
|
|
239
|
+
`;
|
|
240
|
+
if (known.length)
|
|
241
|
+
console.error(` this instance has: ${known.map((u) => u.email).join(", ")}`);
|
|
242
|
+
await sql.end({ timeout: 5 });
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
const out = await sql `
|
|
246
|
+
UPDATE user_sessions SET revoked_at = now()
|
|
247
|
+
WHERE user_id = ${rows[0].id} AND revoked_at IS NULL
|
|
248
|
+
RETURNING id
|
|
249
|
+
`;
|
|
250
|
+
console.log(`✓ password set for ${email}`);
|
|
251
|
+
console.log(` ${out.length} existing session(s) signed out — a password change that leaves the old`);
|
|
252
|
+
console.log(" cookie working is not a password change.");
|
|
253
|
+
await sql.end({ timeout: 5 });
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* `storage` — connect, inspect or disconnect the bucket (LOCK L2).
|
|
258
|
+
*
|
|
259
|
+
* The admin storage SCREEN is M2-23(a) and lives in a dashboard that does
|
|
260
|
+
* not exist yet. Sweep R4 had already deleted every `SNAGSET_STORAGE_S3_*`
|
|
261
|
+
* variable on the grounds that "the bucket is connected in the admin UI",
|
|
262
|
+
* so between those two facts there was no way to connect a bucket on any
|
|
263
|
+
* deployment at all — the pixel tier was unreachable rather than un-built.
|
|
264
|
+
* This is that gap closed with the interface a self-host already has.
|
|
265
|
+
*/
|
|
266
|
+
/**
|
|
267
|
+
* `mcp-token` — mint, list and revoke the credential an agent presents
|
|
268
|
+
* (07 §8.2).
|
|
269
|
+
*
|
|
270
|
+
* A CLI command and not a dashboard screen, for now, because the audience is
|
|
271
|
+
* a developer wiring an MCP client on their own machine and the token has to
|
|
272
|
+
* reach a config file on that machine anyway. The dashboard screen is the
|
|
273
|
+
* v1.x version of the same thing.
|
|
274
|
+
*
|
|
275
|
+
* The token is printed ONCE. Only its hash is stored, so a lost one is
|
|
276
|
+
* revoked and replaced rather than re-displayed — the same rule as the claim
|
|
277
|
+
* URL, for the same reason.
|
|
278
|
+
*/
|
|
279
|
+
if (command === "mcp-token") {
|
|
280
|
+
const { mintMcpToken, DEFAULT_SCOPES, MCP_SCOPES } = await import("./routes/mcpToken.js");
|
|
281
|
+
const sub = process.argv[3] ?? "list";
|
|
282
|
+
if (sub === "list") {
|
|
283
|
+
const rows = await sql `SELECT name, prefix, scopes, last_used_at, revoked_at FROM mcp_tokens ORDER BY created_at`;
|
|
284
|
+
if (!rows.length) {
|
|
285
|
+
console.log("no MCP tokens. Create one with:");
|
|
286
|
+
console.log(" snagset mcp-token create <name> [scope,scope]");
|
|
287
|
+
console.log(` scopes: ${MCP_SCOPES.join(", ")}`);
|
|
288
|
+
console.log(` default: ${DEFAULT_SCOPES.join(", ")}`);
|
|
289
|
+
}
|
|
290
|
+
for (const r of rows) {
|
|
291
|
+
const state = r.revoked_at ? "REVOKED" : r.last_used_at ? `last used ${r.last_used_at.toISOString().slice(0, 10)}` : "never used";
|
|
292
|
+
console.log(`${r.prefix}… ${r.name} [${r.scopes.join(" ")}] ${state}`);
|
|
293
|
+
}
|
|
294
|
+
await sql.end({ timeout: 5 });
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
if (sub === "create") {
|
|
298
|
+
const name = process.argv[4];
|
|
299
|
+
if (!name) {
|
|
300
|
+
console.error("✗ usage: snagset mcp-token create <name> [scope,scope]");
|
|
301
|
+
console.error(` scopes: ${MCP_SCOPES.join(", ")}`);
|
|
302
|
+
await sql.end({ timeout: 5 });
|
|
303
|
+
return 2;
|
|
304
|
+
}
|
|
305
|
+
const scopes = (process.argv[5] ?? DEFAULT_SCOPES.join(",")).split(",").map((x) => x.trim()).filter(Boolean);
|
|
306
|
+
const [org] = await sql `SELECT id FROM orgs WHERE deleted_at IS NULL ORDER BY created_at LIMIT 1`;
|
|
307
|
+
if (!org) {
|
|
308
|
+
console.error("✗ this instance is not claimed yet — there is no org to scope a token to.");
|
|
309
|
+
await sql.end({ timeout: 5 });
|
|
310
|
+
return 2;
|
|
311
|
+
}
|
|
312
|
+
let minted;
|
|
313
|
+
try {
|
|
314
|
+
minted = await mintMcpToken(sql, randomUUID(), {
|
|
315
|
+
orgId: org.id,
|
|
316
|
+
name,
|
|
317
|
+
scopes: scopes,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
console.error(`✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
322
|
+
await sql.end({ timeout: 5 });
|
|
323
|
+
return 2;
|
|
324
|
+
}
|
|
325
|
+
console.log(`\n ${minted.token}\n`);
|
|
326
|
+
console.log(" Shown once — only its hash is stored. Put it in your MCP client:");
|
|
327
|
+
console.log(` { "mcpServers": { "snagset": { "command": "npx", "args": ["-y", "@snagset/mcp"],`);
|
|
328
|
+
console.log(` "env": { "SNAGSET_MCP_TOKEN": "…", "SNAGSET_MCP_HOST": "${cfg.get("SNAGSET_BASE_URL") ?? "https://review.example.com"}" } } } }`);
|
|
329
|
+
console.log(`\n scopes: ${scopes.join(", ")}`);
|
|
330
|
+
await sql.end({ timeout: 5 });
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
if (sub === "revoke") {
|
|
334
|
+
const prefix = process.argv[4];
|
|
335
|
+
if (!prefix) {
|
|
336
|
+
console.error("✗ usage: snagset mcp-token revoke <prefix>");
|
|
337
|
+
await sql.end({ timeout: 5 });
|
|
338
|
+
return 2;
|
|
339
|
+
}
|
|
340
|
+
const rows = await sql `UPDATE mcp_tokens SET revoked_at = now() WHERE prefix = ${prefix} AND revoked_at IS NULL RETURNING name`;
|
|
341
|
+
console.log(rows.count ? `revoked ${rows.count} token(s) with prefix ${prefix}` : `no live token with prefix ${prefix}`);
|
|
342
|
+
await sql.end({ timeout: 5 });
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
console.error(`✗ unknown subcommand "${sub}" — try: list | create | revoke`);
|
|
346
|
+
await sql.end({ timeout: 5 });
|
|
347
|
+
return 2;
|
|
348
|
+
}
|
|
349
|
+
if (command === "storage") {
|
|
350
|
+
const { storageStatus, connectBucket, disconnectBucket, getBucket } = await import("./storage/settings.js");
|
|
351
|
+
const { createKeyring } = await import("./keys.js");
|
|
352
|
+
const { resolveSecretKey } = await import("./boot.js");
|
|
353
|
+
const keys = createKeyring(resolveSecretKey(cfg, cfg.required("SNAGSET_DATA_DIR"), () => { }), cfg.get("SNAGSET_SECRET_KEY_PREVIOUS"));
|
|
354
|
+
const sub = process.argv[3] ?? "status";
|
|
355
|
+
if (sub === "status") {
|
|
356
|
+
const st = await storageStatus(sql, keys);
|
|
357
|
+
if (!st.connected) {
|
|
358
|
+
console.log("storage: NOT connected — annotation mode.");
|
|
359
|
+
console.log(" Comments, anchors and page context all work; screenshots are hidden");
|
|
360
|
+
console.log(" rather than offered and failing (A9b).");
|
|
361
|
+
if (st.unreadable) {
|
|
362
|
+
console.log("\n ⚠ A bucket IS configured but its credentials will not decrypt.");
|
|
363
|
+
console.log(" Almost always a changed SNAGSET_SECRET_KEY (08 §4.7). Reconnect it.");
|
|
364
|
+
}
|
|
365
|
+
console.log("\n Connect one with:");
|
|
366
|
+
console.log(" snagset storage connect <endpoint> <bucket> <access-key> <secret-key> [region]");
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
console.log(`storage: connected to ${st.config.bucket} at ${st.config.endpoint}`);
|
|
370
|
+
// The SOURCE, because "I set the variable and it still uses the old
|
|
371
|
+
// bucket" is unanswerable without it (D60). `since` only exists for a
|
|
372
|
+
// stored connection; an environment one has no connect moment, and
|
|
373
|
+
// printing "since undefined" was the first version of this line.
|
|
374
|
+
console.log(st.source === "env"
|
|
375
|
+
? ` region ${st.config.region ?? "auto"} · from SNAGSET_STORAGE_S3_* in the environment`
|
|
376
|
+
: ` region ${st.config.region ?? "auto"} · connected ${st.connectedAt?.toISOString().slice(0, 10)}`);
|
|
377
|
+
// `status` PROBES, because this is the command somebody runs when
|
|
378
|
+
// screenshots are not appearing, and a "connected" that only means
|
|
379
|
+
// "configured" is the answer that wastes their afternoon.
|
|
380
|
+
const live = await getBucket(sql, keys);
|
|
381
|
+
const probe = live ? await live.probe() : null;
|
|
382
|
+
if (probe && !probe.ok) {
|
|
383
|
+
console.log(`\n ⚠ the ${probe.step} probe FAILED: ${probe.detail}`);
|
|
384
|
+
console.log(" Screenshots and uploads will not work until this is fixed.");
|
|
385
|
+
}
|
|
386
|
+
else if (probe) {
|
|
387
|
+
console.log(" probe ok (write → read → delete).");
|
|
388
|
+
console.log(" screenshots and uploads are advertised to the widget.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
await sql.end({ timeout: 5 });
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
if (sub === "disconnect") {
|
|
395
|
+
const { envBucketConfig } = await import("./storage/settings.js");
|
|
396
|
+
const stillEnv = envBucketConfig(process.env);
|
|
397
|
+
await disconnectBucket(sql);
|
|
398
|
+
// "Back to annotation mode" is TRUE only when nothing else supplies a
|
|
399
|
+
// bucket. Since D60 the environment can, and it wins — so an operator
|
|
400
|
+
// migrating their credentials out of the database (which is exactly what
|
|
401
|
+
// this command is now most used for) was being told the opposite of what
|
|
402
|
+
// had happened.
|
|
403
|
+
if (stillEnv) {
|
|
404
|
+
console.log("storage: the STORED credentials are cleared.");
|
|
405
|
+
console.log(` SNAGSET_STORAGE_S3_* still serves ${stillEnv.config.bucket} — the instance`);
|
|
406
|
+
console.log(" is unchanged for reviewers, and there is now one source of truth.");
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
console.log("storage: disconnected — back to annotation mode.");
|
|
410
|
+
}
|
|
411
|
+
console.log(" Attachment rows keep their storage keys, so reconnecting the SAME");
|
|
412
|
+
console.log(" bucket restores the pictures rather than orphaning them (LOCK L2).");
|
|
413
|
+
await sql.end({ timeout: 5 });
|
|
414
|
+
return 0;
|
|
415
|
+
}
|
|
416
|
+
if (sub === "connect") {
|
|
417
|
+
const [endpoint, bucket, accessKeyId, secretArg, region] = process.argv.slice(4);
|
|
418
|
+
/**
|
|
419
|
+
* `-` reads the secret from STDIN.
|
|
420
|
+
*
|
|
421
|
+
* An argv is visible to every process on the machine via `ps`, and it
|
|
422
|
+
* lands in the shell history file besides. Neither is where a bucket
|
|
423
|
+
* credential should be discoverable, so the documented form is
|
|
424
|
+
*
|
|
425
|
+
* echo "$SECRET" | snagset storage connect <endpoint> <bucket> <key> - <region>
|
|
426
|
+
*
|
|
427
|
+
* and the argv form stays for the case where someone is scripting it
|
|
428
|
+
* against a secret manager that already hands them the value.
|
|
429
|
+
*/
|
|
430
|
+
const secretAccessKey = secretArg === "-"
|
|
431
|
+
? await new Promise((resolve) => {
|
|
432
|
+
let buf = "";
|
|
433
|
+
process.stdin.setEncoding("utf8");
|
|
434
|
+
process.stdin.on("data", (d) => (buf += d));
|
|
435
|
+
process.stdin.on("end", () => resolve(buf.trim()));
|
|
436
|
+
})
|
|
437
|
+
: secretArg;
|
|
438
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
439
|
+
console.error("✗ usage: snagset storage connect <endpoint> <bucket> <access-key> <secret|-> [region]");
|
|
440
|
+
console.error(" `-` reads the secret from stdin, which keeps it out of ps and shell history:");
|
|
441
|
+
console.error(" echo \"$SECRET\" | snagset storage connect https://ams3.digitaloceanspaces.com snagset KEY - ams3");
|
|
442
|
+
console.error(" e.g. snagset storage connect https://<acct>.r2.cloudflarestorage.com snagset KEY SECRET auto");
|
|
443
|
+
await sql.end({ timeout: 5 });
|
|
444
|
+
return 2;
|
|
445
|
+
}
|
|
446
|
+
if (!/^https?:\/\//.test(endpoint)) {
|
|
447
|
+
console.error("✗ the endpoint needs a scheme, e.g. https://…");
|
|
448
|
+
await sql.end({ timeout: 5 });
|
|
449
|
+
return 2;
|
|
450
|
+
}
|
|
451
|
+
console.log("probing the bucket (write → read → delete)…");
|
|
452
|
+
const r = await connectBucket(sql, keys, { endpoint, bucket, region: region ?? "auto" }, { accessKeyId, secretAccessKey });
|
|
453
|
+
if (!r.ok) {
|
|
454
|
+
// Named step, because "it did not work" is three different fixes.
|
|
455
|
+
console.error(`✗ the ${r.step} probe failed: ${r.detail}`);
|
|
456
|
+
console.error(" Nothing was stored. Check the endpoint, the bucket name, and that");
|
|
457
|
+
console.error(" the key can write, read AND delete — delete is what erasure needs.");
|
|
458
|
+
await sql.end({ timeout: 5 });
|
|
459
|
+
return 1;
|
|
460
|
+
}
|
|
461
|
+
console.log(`✓ connected. Screenshots are now advertised to the widget.`);
|
|
462
|
+
await sql.end({ timeout: 5 });
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
console.error(`✗ unknown: snagset storage ${sub} — try: status | connect | disconnect`);
|
|
466
|
+
await sql.end({ timeout: 5 });
|
|
467
|
+
return 2;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* `projects` — what this instance holds.
|
|
471
|
+
*
|
|
472
|
+
* Small, and the reason it exists is not convenience: the project uid is
|
|
473
|
+
* minted at claim time and is the one value every script tag, every doc and
|
|
474
|
+
* every allow-list entry has to agree on. Writing it into a README makes the
|
|
475
|
+
* README wrong the first time anyone re-claims, which is exactly what
|
|
476
|
+
* happened once already. A command that prints the truth is cheaper than a
|
|
477
|
+
* document that repeats it.
|
|
478
|
+
*/
|
|
479
|
+
if (command === "projects") {
|
|
480
|
+
/**
|
|
481
|
+
* `projects add <name> [origins]` — D63's multi-site, from a shell.
|
|
482
|
+
*
|
|
483
|
+
* This command LISTED and could not add, which is the same gap the
|
|
484
|
+
* dashboard had: the instance could hold exactly the one project
|
|
485
|
+
* `bootstrap` made. The dashboard is the primary way now; this is for the
|
|
486
|
+
* scripted case — a deploy step that provisions three client sites, or an
|
|
487
|
+
* operator who is already in a terminal.
|
|
488
|
+
*/
|
|
489
|
+
if (process.argv[3] === "add") {
|
|
490
|
+
const name = process.argv[4];
|
|
491
|
+
if (!name) {
|
|
492
|
+
console.error("✗ usage: snagset projects add <name> [origin,origin]");
|
|
493
|
+
console.error(' e.g. snagset projects add "Acme redesign" https://staging.acme.com');
|
|
494
|
+
await sql.end({ timeout: 5 });
|
|
495
|
+
return 2;
|
|
496
|
+
}
|
|
497
|
+
const origins = (process.argv[5] ?? "").split(",").map((o) => o.trim()).filter(Boolean);
|
|
498
|
+
// The org and an owner to attribute Round 1 to. A round is a studio act
|
|
499
|
+
// (D2) and `rounds.created_by` references `users`, so there has to be a
|
|
500
|
+
// real one — which on an unclaimed instance there is not.
|
|
501
|
+
const [owner] = await sql `
|
|
502
|
+
SELECT m.org_id, m.user_id FROM memberships m
|
|
503
|
+
JOIN users u ON u.id = m.user_id AND u.deleted_at IS NULL
|
|
504
|
+
WHERE m.role = 'owner' ORDER BY m.org_id LIMIT 1
|
|
505
|
+
`;
|
|
506
|
+
if (!owner) {
|
|
507
|
+
console.error("✗ this instance is not claimed yet — run `snagset bootstrap` first.");
|
|
508
|
+
await sql.end({ timeout: 5 });
|
|
509
|
+
return 2;
|
|
510
|
+
}
|
|
511
|
+
let created;
|
|
512
|
+
try {
|
|
513
|
+
created = await sql.begin(async (tx) => {
|
|
514
|
+
await tx `SELECT set_config('app.org_id', ${owner.org_id}, true)`;
|
|
515
|
+
return createProject(tx, {
|
|
516
|
+
orgId: owner.org_id,
|
|
517
|
+
name,
|
|
518
|
+
createdBy: owner.user_id,
|
|
519
|
+
origins,
|
|
520
|
+
graceHours: cfg.int("SNAGSET_WRITE_MODE_GRACE_HOURS"),
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
console.error(`✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
526
|
+
await sql.end({ timeout: 5 });
|
|
527
|
+
return 2;
|
|
528
|
+
}
|
|
529
|
+
console.log(`\n✓ ${created.name} ${created.uid}\n`);
|
|
530
|
+
console.log(" Paste this into the site, immediately before </body>:\n");
|
|
531
|
+
console.log(` <script src="${cfg.get("SNAGSET_BASE_URL") ?? "http://localhost:8080"}/snag.js" ` +
|
|
532
|
+
`data-snag-site="${created.uid}" defer></script>\n`);
|
|
533
|
+
console.log(" Who sees the toolbar is a dashboard setting (Sites screen), checked\n" +
|
|
534
|
+
" by the tag on load — change it any time without re-pasting.\n");
|
|
535
|
+
if (origins.length)
|
|
536
|
+
console.log(` origins allowed: ${origins.join(", ")}`);
|
|
537
|
+
else
|
|
538
|
+
console.log(" ⚠ no origins allowed yet, so nothing can talk to it — " +
|
|
539
|
+
`run \`snagset allow-origin ${created.uid} https://your-site.com\``);
|
|
540
|
+
await sql.end({ timeout: 5 });
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
const rows = await sql `
|
|
544
|
+
SELECT p.uid, p.name,
|
|
545
|
+
(SELECT count(*)::int FROM threads t WHERE t.project_id = p.id AND t.deleted_at IS NULL) AS threads,
|
|
546
|
+
(SELECT count(*)::int FROM project_origins o WHERE o.project_id = p.id) AS origins
|
|
547
|
+
FROM projects p WHERE p.deleted_at IS NULL ORDER BY p.created_at
|
|
548
|
+
`;
|
|
549
|
+
if (!rows.length)
|
|
550
|
+
console.log("no projects — the instance is not claimed yet (run `snagset bootstrap`)");
|
|
551
|
+
for (const r of rows) {
|
|
552
|
+
console.log(`${r.uid} ${r.name} · ${r.threads} snag(s) · ${r.origins} allow-listed origin(s)`);
|
|
553
|
+
}
|
|
554
|
+
if (rows.length)
|
|
555
|
+
console.log("\nadd another: snagset projects add <name> [origin,origin]");
|
|
556
|
+
await sql.end({ timeout: 5 });
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 08 §3.5 D2's "human click", in a build that has no dashboard to click in.
|
|
561
|
+
*
|
|
562
|
+
* `origins <uid>` lists what is allow-listed and what has been SUGGESTED —
|
|
563
|
+
* the origins that fetched config and were refused. `allow-origin <uid>
|
|
564
|
+
* <origin>` is the approval. Nothing is ever allow-listed without one of
|
|
565
|
+
* these being typed, because `Origin` is attacker-controllable and an
|
|
566
|
+
* auto-allow is an attacker-controlled header granting access.
|
|
567
|
+
*/
|
|
568
|
+
if (command === "origins" || command === "allow-origin") {
|
|
569
|
+
const uid = process.argv[3];
|
|
570
|
+
if (!uid) {
|
|
571
|
+
console.error(`✗ usage: snagset ${command} <project-uid>${command === "allow-origin" ? " <origin>" : ""}`);
|
|
572
|
+
await sql.end({ timeout: 5 });
|
|
573
|
+
return 2;
|
|
574
|
+
}
|
|
575
|
+
const [proj] = await sql `
|
|
576
|
+
SELECT id, org_id, name FROM projects WHERE uid = ${uid} AND deleted_at IS NULL
|
|
577
|
+
`;
|
|
578
|
+
if (!proj) {
|
|
579
|
+
console.error(`✗ no project with uid ${uid}`);
|
|
580
|
+
await sql.end({ timeout: 5 });
|
|
581
|
+
return 1;
|
|
582
|
+
}
|
|
583
|
+
if (command === "allow-origin") {
|
|
584
|
+
const origin = process.argv[4]?.toLowerCase().replace(/\/+$/, "");
|
|
585
|
+
if (!origin || !/^https?:\/\/[a-z0-9*.\-]+(:[0-9]{1,5})?$/.test(origin)) {
|
|
586
|
+
console.error("✗ an origin is scheme + host + optional port, e.g. https://kilnwork.com");
|
|
587
|
+
await sql.end({ timeout: 5 });
|
|
588
|
+
return 2;
|
|
589
|
+
}
|
|
590
|
+
await sql `
|
|
591
|
+
INSERT INTO project_origins (id, org_id, project_id, pattern, is_wildcard)
|
|
592
|
+
VALUES (${randomUUID()}, ${proj.org_id}, ${proj.id}, ${origin}, ${origin.includes("*")})
|
|
593
|
+
ON CONFLICT (project_id, pattern) DO NOTHING
|
|
594
|
+
`;
|
|
595
|
+
// Approved, so it is no longer a suggestion.
|
|
596
|
+
await sql `DELETE FROM origin_violations WHERE project_id = ${proj.id} AND origin = ${origin}`;
|
|
597
|
+
console.log(`✓ ${origin} may now write to ${proj.name} (${uid})`);
|
|
598
|
+
await sql.end({ timeout: 5 });
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
const allowed = await sql `
|
|
602
|
+
SELECT pattern FROM project_origins WHERE project_id = ${proj.id} ORDER BY pattern
|
|
603
|
+
`;
|
|
604
|
+
const seen = await sql `
|
|
605
|
+
SELECT origin, hits, write_attempted FROM origin_violations
|
|
606
|
+
WHERE project_id = ${proj.id} ORDER BY last_seen_at DESC
|
|
607
|
+
`;
|
|
608
|
+
console.log(`${proj.name} (${uid})`);
|
|
609
|
+
console.log(`\n allow-listed:`);
|
|
610
|
+
console.log(allowed.length ? allowed.map((r) => ` ${r.pattern}`).join("\n") : " (none yet)");
|
|
611
|
+
if (seen.length) {
|
|
612
|
+
console.log(`\n seen and NOT allow-listed — approve with \`snagset allow-origin ${uid} <origin>\`:`);
|
|
613
|
+
for (const r of seen) {
|
|
614
|
+
console.log(` ${r.origin} ${r.hits} hit(s)${r.write_attempted ? " — tried to WRITE" : ""}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
await sql.end({ timeout: 5 });
|
|
618
|
+
return 0;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* `link` — mint, list and revoke review links without the dashboard.
|
|
622
|
+
*
|
|
623
|
+
* T4 (`plan/05-next-run.md`): after T2 put owner auth on the HTTP
|
|
624
|
+
* review-link routes, minting one needs a cookie + CSRF dance that has no
|
|
625
|
+
* `curl` recipe and no UI yet (T9). This is the same act, run by whoever
|
|
626
|
+
* holds `DATABASE_URL` — which is already more authority than any owner
|
|
627
|
+
* session grants, so no additional auth check belongs here. The SQL below
|
|
628
|
+
* is copied from `routes/reviewLinks.ts` on purpose: same insert, same
|
|
629
|
+
* sha256-only storage of the token, same "revoke bumps session_epoch in the
|
|
630
|
+
* same transaction" shape that makes revoking mean anything (05 §4.4).
|
|
631
|
+
*/
|
|
632
|
+
if (command === "link") {
|
|
633
|
+
const sub = process.argv[3];
|
|
634
|
+
if (!["mint", "list", "revoke"].includes(sub ?? "")) {
|
|
635
|
+
console.error('✗ usage: snagset link mint <project-uid> "<name>" [--days N] [--round N]');
|
|
636
|
+
console.error(" snagset link list <project-uid>");
|
|
637
|
+
console.error(" snagset link revoke <link-id>");
|
|
638
|
+
await sql.end({ timeout: 5 });
|
|
639
|
+
return 2;
|
|
640
|
+
}
|
|
641
|
+
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest();
|
|
642
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
643
|
+
const rowId = (wire, prefix) => {
|
|
644
|
+
const bare = wire.startsWith(prefix) ? wire.slice(prefix.length) : wire;
|
|
645
|
+
return UUID.test(bare) ? bare.toLowerCase() : null;
|
|
646
|
+
};
|
|
647
|
+
// §3.4's default set — same three permissions `reviewLinks.ts` grants
|
|
648
|
+
// when a mint request does not specify any.
|
|
649
|
+
const DEFAULT_PERMISSIONS = ["thread:create", "comment:create", "thread:resolve"];
|
|
650
|
+
if (sub === "mint") {
|
|
651
|
+
const uid = process.argv[4];
|
|
652
|
+
const name = (process.argv[5] ?? "").trim().slice(0, 120);
|
|
653
|
+
if (!uid || !name) {
|
|
654
|
+
console.error('✗ usage: snagset link mint <project-uid> "<name>" [--days N] [--round N]');
|
|
655
|
+
await sql.end({ timeout: 5 });
|
|
656
|
+
return 2;
|
|
657
|
+
}
|
|
658
|
+
const rest = process.argv.slice(6);
|
|
659
|
+
let daysArg = null;
|
|
660
|
+
let roundArg = null;
|
|
661
|
+
for (let i = 0; i < rest.length; i++) {
|
|
662
|
+
if (rest[i] === "--days")
|
|
663
|
+
daysArg = Number(rest[++i]);
|
|
664
|
+
else if (rest[i] === "--round")
|
|
665
|
+
roundArg = Number(rest[++i]);
|
|
666
|
+
}
|
|
667
|
+
const [project] = await sql `
|
|
668
|
+
SELECT id, org_id FROM projects WHERE uid = ${uid} AND deleted_at IS NULL
|
|
669
|
+
`;
|
|
670
|
+
if (!project) {
|
|
671
|
+
console.error(`✗ no project with uid ${uid}`);
|
|
672
|
+
await sql.end({ timeout: 5 });
|
|
673
|
+
return 1;
|
|
674
|
+
}
|
|
675
|
+
let roundId = null;
|
|
676
|
+
if (roundArg !== null) {
|
|
677
|
+
if (!Number.isInteger(roundArg)) {
|
|
678
|
+
console.error("✗ --round takes a round NUMBER (1, 2, …), not an id.");
|
|
679
|
+
await sql.end({ timeout: 5 });
|
|
680
|
+
return 2;
|
|
681
|
+
}
|
|
682
|
+
const [round] = await sql `
|
|
683
|
+
SELECT id FROM rounds WHERE project_id = ${project.id} AND number = ${roundArg} AND deleted_at IS NULL
|
|
684
|
+
`;
|
|
685
|
+
if (!round) {
|
|
686
|
+
console.error(`✗ project ${uid} has no round ${roundArg}.`);
|
|
687
|
+
await sql.end({ timeout: 5 });
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
roundId = round.id;
|
|
691
|
+
}
|
|
692
|
+
// Same clamp as the HTTP route (§4.5.1): one default, one ceiling, and
|
|
693
|
+
// the clamp lives on the server side rather than being trusted to the
|
|
694
|
+
// caller.
|
|
695
|
+
const ttlDaysDefault = cfg.int("SNAGSET_REVIEW_LINK_TTL_DAYS");
|
|
696
|
+
const maxTtlDays = cfg.int("SNAGSET_REVIEW_LINK_MAX_TTL_DAYS");
|
|
697
|
+
if (daysArg !== null && (!Number.isFinite(daysArg) || daysArg <= 0)) {
|
|
698
|
+
console.error("✗ --days must be a positive number.");
|
|
699
|
+
await sql.end({ timeout: 5 });
|
|
700
|
+
return 2;
|
|
701
|
+
}
|
|
702
|
+
const days = Math.min(daysArg ?? ttlDaysDefault, maxTtlDays);
|
|
703
|
+
const DAY = 86_400_000;
|
|
704
|
+
const expiresAt = new Date(Date.now() + days * DAY);
|
|
705
|
+
// 32 bytes, unpadded base64url, 43 chars — foundations §7. Stored only
|
|
706
|
+
// as sha256, so this is the token's one and only appearance, same as
|
|
707
|
+
// the HTTP mint response.
|
|
708
|
+
const token = randomBytes(32).toString("base64url");
|
|
709
|
+
const id = randomUUID();
|
|
710
|
+
await sql `
|
|
711
|
+
INSERT INTO review_links
|
|
712
|
+
(id, org_id, project_id, round_id, token_hash, name, environments, permissions,
|
|
713
|
+
allow_name_override, expires_at)
|
|
714
|
+
VALUES
|
|
715
|
+
(${id}, ${project.org_id}, ${project.id}, ${roundId}, ${sha256(token)}, ${name},
|
|
716
|
+
${sql.array(["*"])}, ${sql.array(DEFAULT_PERMISSIONS)}, true, ${expiresAt})
|
|
717
|
+
`;
|
|
718
|
+
console.log(`✓ minted "${name}" for ${uid}, expires ${expiresAt.toISOString().slice(0, 10)}`);
|
|
719
|
+
console.log("\nThe token below appears once — it is stored as a sha256 and this instance");
|
|
720
|
+
console.log("cannot print it again. Paste it after the site's URL:");
|
|
721
|
+
console.log(`\n #snag_k=${token}\n`);
|
|
722
|
+
await sql.end({ timeout: 5 });
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
if (sub === "list") {
|
|
726
|
+
const uid = process.argv[4];
|
|
727
|
+
if (!uid) {
|
|
728
|
+
console.error("✗ usage: snagset link list <project-uid>");
|
|
729
|
+
await sql.end({ timeout: 5 });
|
|
730
|
+
return 2;
|
|
731
|
+
}
|
|
732
|
+
const [project] = await sql `
|
|
733
|
+
SELECT id FROM projects WHERE uid = ${uid} AND deleted_at IS NULL
|
|
734
|
+
`;
|
|
735
|
+
if (!project) {
|
|
736
|
+
console.error(`✗ no project with uid ${uid}`);
|
|
737
|
+
await sql.end({ timeout: 5 });
|
|
738
|
+
return 1;
|
|
739
|
+
}
|
|
740
|
+
const rows = await sql `
|
|
741
|
+
SELECT id, name, created_at, expires_at, revoked_at, use_count, last_used_at
|
|
742
|
+
FROM review_links WHERE project_id = ${project.id} ORDER BY created_at DESC
|
|
743
|
+
`;
|
|
744
|
+
if (!rows.length) {
|
|
745
|
+
console.log(`no review links for ${uid} yet — mint one with \`snagset link mint ${uid} "<name>"\``);
|
|
746
|
+
await sql.end({ timeout: 5 });
|
|
747
|
+
return 0;
|
|
748
|
+
}
|
|
749
|
+
// The id column prints the FULL wire id — `link revoke` takes exactly
|
|
750
|
+
// this string. Truncating it for column width (an earlier version of
|
|
751
|
+
// this command did) produces an id that looks copyable and is not:
|
|
752
|
+
// `rowId()` requires a full uuid tail and silently rejects a shortened
|
|
753
|
+
// one, so revoke failed with "usage" for an id copied straight out of
|
|
754
|
+
// this table. Everything else may wrap; the id must not.
|
|
755
|
+
console.log(`id name created expires revoked uses last used`);
|
|
756
|
+
for (const r of rows) {
|
|
757
|
+
const id = ID_PREFIX.link + r.id;
|
|
758
|
+
const created = r.created_at.toISOString().slice(0, 10);
|
|
759
|
+
const expires = r.expires_at.toISOString().slice(0, 10);
|
|
760
|
+
const revoked = r.revoked_at ? "yes" : "no";
|
|
761
|
+
const lastUsed = r.last_used_at ? r.last_used_at.toISOString().slice(0, 10) : "never";
|
|
762
|
+
console.log(`${id.padEnd(46)}${r.name.slice(0, 20).padEnd(21)}${created.padEnd(12)}${expires.padEnd(12)}${revoked.padEnd(9)}${String(r.use_count).padEnd(6)}${lastUsed}`);
|
|
763
|
+
}
|
|
764
|
+
await sql.end({ timeout: 5 });
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
767
|
+
// sub === "revoke"
|
|
768
|
+
const raw = process.argv[4];
|
|
769
|
+
const id = raw ? rowId(raw, ID_PREFIX.link) : null;
|
|
770
|
+
if (!id) {
|
|
771
|
+
console.error(`✗ usage: snagset link revoke <link-id>`);
|
|
772
|
+
await sql.end({ timeout: 5 });
|
|
773
|
+
return 2;
|
|
774
|
+
}
|
|
775
|
+
const [link] = await sql `
|
|
776
|
+
SELECT id, project_id, revoked_at FROM review_links WHERE id = ${id}
|
|
777
|
+
`;
|
|
778
|
+
if (!link) {
|
|
779
|
+
console.error(`✗ no review link with id ${raw}`);
|
|
780
|
+
await sql.end({ timeout: 5 });
|
|
781
|
+
return 1;
|
|
782
|
+
}
|
|
783
|
+
if (link.revoked_at) {
|
|
784
|
+
console.log(`already revoked — nothing to do.`);
|
|
785
|
+
await sql.end({ timeout: 5 });
|
|
786
|
+
return 0;
|
|
787
|
+
}
|
|
788
|
+
// BOTH in one transaction, same as the HTTP route: `revoked_at` stops
|
|
789
|
+
// further exchanges, and the `session_epoch` bump is what signs out
|
|
790
|
+
// everyone who already redeemed it — without it a revoked link keeps
|
|
791
|
+
// working for its existing holders (05 §4.4).
|
|
792
|
+
await sql.begin(async (tx) => {
|
|
793
|
+
await tx `UPDATE review_links SET revoked_at = now() WHERE id = ${id} AND revoked_at IS NULL`;
|
|
794
|
+
await tx `UPDATE projects SET session_epoch = session_epoch + 1 WHERE id = ${link.project_id}`;
|
|
795
|
+
});
|
|
796
|
+
console.log(`✓ revoked. every session minted from it is out within 10 s.`);
|
|
797
|
+
console.log(" (this process cannot count how many that is — the bump is what matters, not the count.)");
|
|
798
|
+
await sql.end({ timeout: 5 });
|
|
799
|
+
return 0;
|
|
800
|
+
}
|
|
801
|
+
// Step 7's escape hatch. Before boot(), because boot() would see the
|
|
802
|
+
// outstanding token this replaces and decline to mint.
|
|
803
|
+
/**
|
|
804
|
+
* `bootstrap` — claim the instance.
|
|
805
|
+
*
|
|
806
|
+
* FROM THE ENVIRONMENT FIRST, and that ordering is the whole fix. This
|
|
807
|
+
* command used to go straight to minting a claim URL, so an operator who had
|
|
808
|
+
* set all four `SNAGSET_BOOTSTRAP_*` variables and then ran the command
|
|
809
|
+
* literally named `bootstrap` got a URL to click — the one command whose name
|
|
810
|
+
* promises exactly the thing it did not do. Measured on a cold install: set
|
|
811
|
+
* the variables, run `bootstrap`, and it printed a claim URL for a browser
|
|
812
|
+
* that was not going to be opened.
|
|
813
|
+
*
|
|
814
|
+
* `bootstrapFromEnv` is idempotent (it returns false the moment an org
|
|
815
|
+
* exists), so this cannot double-claim, and running it here means both the
|
|
816
|
+
* scripted path and the interactive one reach the same place.
|
|
817
|
+
*/
|
|
818
|
+
if (command === "bootstrap") {
|
|
819
|
+
const claimed = await bootstrapFromEnv(sql, cfg, (level, msg) => console.log(`${level}: ${msg}`));
|
|
820
|
+
if (claimed) {
|
|
821
|
+
console.log("\n✓ claimed from the environment — no URL to open. Start it with `snagset serve`.");
|
|
822
|
+
await sql.end({ timeout: 5 });
|
|
823
|
+
return 0;
|
|
824
|
+
}
|
|
825
|
+
const url = await mintClaim(sql, cfg, cfg.required("SNAGSET_DATA_DIR"));
|
|
826
|
+
console.log("\n" + claimBanner(url));
|
|
827
|
+
console.log("\nAny previous claim URL is now void.");
|
|
828
|
+
// Named here rather than left to the docs: an operator running `bootstrap`
|
|
829
|
+
// by hand is, by definition, at a shell — which is where the scripted path
|
|
830
|
+
// is available and the URL is not needed.
|
|
831
|
+
console.log("\nPrefer no browser? Set SNAGSET_BOOTSTRAP_OWNER_EMAIL, " +
|
|
832
|
+
"SNAGSET_BOOTSTRAP_OWNER_PASSWORD_FILE, SNAGSET_BOOTSTRAP_PROJECT_NAME and " +
|
|
833
|
+
"SNAGSET_BOOTSTRAP_PROJECT_ORIGINS, then run this again — `snagset env` lists them.");
|
|
834
|
+
await sql.end({ timeout: 5 });
|
|
835
|
+
return 0;
|
|
836
|
+
}
|
|
837
|
+
const result = await boot(sql, cfg, {
|
|
838
|
+
log: (level, msg) => console[level === "error" ? "error" : "log"](`${level}: ${msg}`),
|
|
839
|
+
});
|
|
840
|
+
const build = describeBuild();
|
|
841
|
+
const app = createApp({
|
|
842
|
+
sql,
|
|
843
|
+
cfg,
|
|
844
|
+
// Already generated or read by boot(); resolving again is a no-op that
|
|
845
|
+
// returns the same key from the same file.
|
|
846
|
+
secretKey: resolveSecretKey(cfg, result.dataDir, () => { }),
|
|
847
|
+
state: result.state,
|
|
848
|
+
version: pkg.version,
|
|
849
|
+
commit: build.commit,
|
|
850
|
+
sourceUrl: pkg.repository?.url ?? "https://github.com/snagset/snagset",
|
|
851
|
+
modified: build.modified,
|
|
852
|
+
});
|
|
853
|
+
const port = cfg.int("SNAGSET_PORT");
|
|
854
|
+
const hostname = cfg.get("SNAGSET_BIND_ADDRESS") || "0.0.0.0";
|
|
855
|
+
serve({ fetch: app.fetch, port, hostname }, (info) => {
|
|
856
|
+
console.log(`snagset ${pkg.version} listening on http://${hostname}:${info.port} — state ${result.state}`);
|
|
857
|
+
});
|
|
858
|
+
startJobTicker(sql, cfg, result.dataDir);
|
|
859
|
+
return 0;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* 08 §3.3's "ticks its own jobs in-process" — the LOCAL half of the schedule.
|
|
863
|
+
*
|
|
864
|
+
* The engine itself is a bounded call that owns no timer (see `jobs/index.ts`);
|
|
865
|
+
* this is the one place a timer exists, and it exists only because a long-lived
|
|
866
|
+
* `npx snagset` has nothing else to wake it. On the serverless path L2 calls the
|
|
867
|
+
* same `runDueJobs` from a cron request and none of this runs.
|
|
868
|
+
*
|
|
869
|
+
* Four things it does deliberately:
|
|
870
|
+
*
|
|
871
|
+
* · `unref()`, so a tick pending at shutdown does not hold the process open.
|
|
872
|
+
* Every job is idempotent and bounded, so being cut off mid-tick costs a
|
|
873
|
+
* repeat, not a corruption.
|
|
874
|
+
* · One tick at a time, guarded in-process. The advisory lock already stops
|
|
875
|
+
* two INSTANCES colliding; this stops one instance stacking ticks when a
|
|
876
|
+
* job runs longer than the interval — which the lock would handle
|
|
877
|
+
* correctly but wastefully, by opening a transaction to be told no.
|
|
878
|
+
* · The bucket is resolved PER TICK rather than captured once. An operator
|
|
879
|
+
* who runs `snagset storage connect` while the server is up should not
|
|
880
|
+
* have to restart it for uploads to start being reaped.
|
|
881
|
+
* · Failures are logged and swallowed. A reaper that cannot run is not a
|
|
882
|
+
* reason to take the API down with it.
|
|
883
|
+
*/
|
|
884
|
+
function startJobTicker(sql, cfg, dataDir) {
|
|
885
|
+
const EVERY_MS = 60_000;
|
|
886
|
+
let running = false;
|
|
887
|
+
const tick = async () => {
|
|
888
|
+
if (running)
|
|
889
|
+
return;
|
|
890
|
+
running = true;
|
|
891
|
+
try {
|
|
892
|
+
const { runDueJobs, summarise } = await import("./jobs/index.js");
|
|
893
|
+
const { l1Jobs } = await import("./jobs/reaper.js");
|
|
894
|
+
const { getBucket } = await import("./storage/settings.js");
|
|
895
|
+
const { createKeyring } = await import("./keys.js");
|
|
896
|
+
const { selectMailer } = await import("./mail/index.js");
|
|
897
|
+
const { deliverEmail } = await import("./mail/notify.js");
|
|
898
|
+
const keys = createKeyring(resolveSecretKey(cfg, dataDir, () => { }), cfg.get("SNAGSET_SECRET_KEY_PREVIOUS"));
|
|
899
|
+
const bucket = await getBucket(sql, keys).catch(() => null);
|
|
900
|
+
// Resolved per tick for the same reason as the bucket: an operator who
|
|
901
|
+
// sets a mail key and restarts nothing should still get their mail.
|
|
902
|
+
const mailer = selectMailer(cfg);
|
|
903
|
+
const jobs = [...l1Jobs(bucket), deliverEmail(mailer, cfg.get("SNAGSET_BASE_URL") ?? "", keys)];
|
|
904
|
+
const summary = await runDueJobs(sql, jobs, {
|
|
905
|
+
// Well under the interval, so a tick can never overrun into the next
|
|
906
|
+
// one even when every job has work.
|
|
907
|
+
budgetMs: 20_000,
|
|
908
|
+
log: (m) => console.log(m),
|
|
909
|
+
});
|
|
910
|
+
// Silent when there was nothing to do: a line a minute saying "0" is a
|
|
911
|
+
// log nobody reads, and the first thing an operator turns off.
|
|
912
|
+
if (summary.ran.some((r) => r.did > 0) || summary.failed.length) {
|
|
913
|
+
console.log(summarise(summary));
|
|
914
|
+
}
|
|
915
|
+
for (const f of summary.failed)
|
|
916
|
+
console.error(`jobs: ${f.name} failed: ${f.error}`);
|
|
917
|
+
}
|
|
918
|
+
catch (e) {
|
|
919
|
+
console.error(`jobs: tick failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
920
|
+
}
|
|
921
|
+
finally {
|
|
922
|
+
running = false;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
// First tick shortly after boot rather than immediately: a project whose
|
|
926
|
+
// grace window closed while the server was down should be downgraded on
|
|
927
|
+
// startup, but not while migrations and the boot report are still printing.
|
|
928
|
+
setTimeout(() => void tick(), 5_000).unref();
|
|
929
|
+
setInterval(() => void tick(), EVERY_MS).unref();
|
|
930
|
+
}
|
|
931
|
+
main()
|
|
932
|
+
.then((code) => {
|
|
933
|
+
// `serve` keeps the process alive on its own; the one-shot commands do not.
|
|
934
|
+
if (code !== 0 || (process.argv[2] ?? "serve") !== "serve")
|
|
935
|
+
process.exit(code);
|
|
936
|
+
})
|
|
937
|
+
.catch((e) => {
|
|
938
|
+
if (e instanceof ConfigError) {
|
|
939
|
+
console.error(`✗ ${e.message}`);
|
|
940
|
+
console.error(` ${e.fix}`);
|
|
941
|
+
process.exit(2);
|
|
942
|
+
}
|
|
943
|
+
if (e instanceof MigrationError) {
|
|
944
|
+
console.error(`✗ migrate: ${e.message}`);
|
|
945
|
+
process.exit(e.exitCode);
|
|
946
|
+
}
|
|
947
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
948
|
+
console.error(`✗ ${msg}`);
|
|
949
|
+
process.exit(/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|password|SASL|SSL/i.test(msg) ? 3 : 1);
|
|
950
|
+
});
|
|
951
|
+
//# sourceMappingURL=bin.js.map
|