@timqi/pier 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.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,281 @@
1
+ import { Type } from "typebox";
2
+ import { TaskDefinitions, record, requiredString } from "./definitions.js";
3
+ import { TaskMessenger } from "./messages.js";
4
+ import { TaskStore } from "./store.js";
5
+ // JSON-Schema enum emits ~1/3 the tokens of typebox's anyOf-of-consts.
6
+ const strEnum = (...values) => Type.Unsafe({ type: "string", enum: [...values] });
7
+ /**
8
+ * Drop the fields with nothing in them instead of sending `null`.
9
+ *
10
+ * A run summary has eighteen fields and most are empty for most of a run's
11
+ * life; a model reads "absent" and "null" the same way. On a `get` that lists
12
+ * several runs this is a third of the payload.
13
+ *
14
+ * The input names every field — a summary that forgot one would otherwise pass
15
+ * as "that field was empty" — and the result is the type with the empty ones
16
+ * gone, which is why those are declared optional above.
17
+ */
18
+ const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null && v !== undefined));
19
+ const summarize = (run, pendingDecisionId) => defined({
20
+ runId: run.id,
21
+ taskId: run.taskId,
22
+ taskName: run.context.definition.name,
23
+ state: run.state,
24
+ triggerSource: run.triggerSource,
25
+ groupId: run.groupId,
26
+ sessionMode: run.sessionMode,
27
+ targetSessionId: run.targetSessionId,
28
+ callbackSessionId: run.callbackSessionId,
29
+ callbackState: run.callbackState,
30
+ pendingDecisionId,
31
+ depth: run.depth,
32
+ queuedAt: run.queuedAt,
33
+ startedAt: run.startedAt,
34
+ finishedAt: run.finishedAt,
35
+ result: run.result,
36
+ error: run.error,
37
+ skipReason: run.skipReason,
38
+ });
39
+ const summarizeGroup = (group, members, messages) => defined({
40
+ groupId: group.id,
41
+ join: group.join,
42
+ state: group.finishedAt ? "finished" : "running",
43
+ callbackState: group.callbackState,
44
+ winnerRunId: group.winnerRunId,
45
+ members: members.map((run) => summarize(run, messages.openDecisionId(run.id))),
46
+ });
47
+ // Model-facing draft shape. Guidance only: runtime truth stays in parseDraft,
48
+ // so schema drift can never loosen boundary validation.
49
+ const DraftSchema = Type.Object({
50
+ name: Type.String(),
51
+ description: Type.Optional(Type.String()),
52
+ trigger: Type.Optional(Type.Union([
53
+ Type.Object({ type: Type.Literal("manual") }),
54
+ Type.Object({ type: Type.Literal("cron"), expression: Type.String(), timezone: Type.String() }),
55
+ Type.Object({
56
+ type: Type.Literal("watch"),
57
+ script: Type.String(),
58
+ cwd: Type.String(),
59
+ intervalSeconds: Type.Number(),
60
+ mode: strEnum("once", "repeat"),
61
+ }),
62
+ ])),
63
+ action: Type.Union([
64
+ Type.Object({
65
+ type: Type.Literal("agent"),
66
+ session: Type.Union([
67
+ Type.Object({ mode: Type.Literal("fresh"), cwd: Type.String() }),
68
+ Type.Object({ mode: Type.Literal("fork"), cwd: Type.Optional(Type.String()) }),
69
+ Type.Object({ mode: Type.Literal("reuse"), sessionId: Type.String() }),
70
+ ]),
71
+ prompt: Type.String(),
72
+ // No `capabilities` here on purpose: a child gets the same tools as any
73
+ // Pier session, so the model never spends a decision on it. Read-only
74
+ // children stay configurable through the Console and HTTP.
75
+ launch: Type.Optional(Type.Object({
76
+ model: Type.Optional(Type.Object({ provider: Type.String(), id: Type.String() })),
77
+ thinking: Type.Optional(Type.String()),
78
+ })),
79
+ }),
80
+ Type.Object({ type: Type.Literal("bash"), script: Type.String(), cwd: Type.String() }),
81
+ Type.Object({ type: Type.Literal("task"), taskId: Type.String() }),
82
+ ]),
83
+ callback: Type.Optional(Type.Union([
84
+ Type.Object({ type: Type.Literal("none") }),
85
+ Type.Object({ type: Type.Literal("origin") }),
86
+ Type.Object({ type: Type.Literal("session"), sessionId: Type.String() }),
87
+ ])),
88
+ timeoutSeconds: Type.Optional(Type.Number()),
89
+ });
90
+ /** The model-facing `task` tool contract, injected into the agent seam as data. */
91
+ export function taskToolSpec(execute) {
92
+ return {
93
+ name: "task",
94
+ label: "Pier Task",
95
+ description: "Manage durable Pier tasks and subagents. Agent tasks support reused, fresh, or forked sessions. Run executes a stored task by task_id, a one-shot subagent from an inline task draft, or a core-joined fan-out via tasks[] with join all|first. Get accepts run_id, group_id, or task_id for that task's recent runs. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions.",
96
+ parameters: Type.Object({
97
+ operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply"),
98
+ task_id: Type.Optional(Type.String()),
99
+ run_id: Type.Optional(Type.String()),
100
+ group_id: Type.Optional(Type.String()),
101
+ message_id: Type.Optional(Type.String()),
102
+ message: Type.Optional(Type.String()),
103
+ reason: Type.Optional(strEnum("progress", "decision")),
104
+ session_mode: Type.Optional(strEnum("fresh", "fork")),
105
+ task: Type.Optional(DraftSchema),
106
+ // The same draft again, spelled out, cost more tokens in every session
107
+ // than the whole rest of this contract. One copy is the guidance; this
108
+ // one points at it, and `parseDraft` is what actually validates either.
109
+ tasks: Type.Optional(Type.Unsafe({
110
+ type: "array",
111
+ description: "2+ entries, each either a task draft shaped exactly like `task`, or {task_id}.",
112
+ items: { type: "object" },
113
+ })),
114
+ join: Type.Optional(strEnum("all", "first")),
115
+ input: Type.Optional(Type.Unknown()),
116
+ callback: Type.Optional(strEnum("origin", "none")),
117
+ callback_session_id: Type.Optional(Type.String()),
118
+ }),
119
+ execute,
120
+ };
121
+ }
122
+ export async function handleTaskTool(host, definitions, store, messages, raw, callerSessionId) {
123
+ const input = record(raw);
124
+ if (!input)
125
+ throw new Error("task tool parameters required");
126
+ const active = store.findActiveRunForTarget(callerSessionId);
127
+ if (input.operation === "list")
128
+ return definitions.list().filter((task) => task.kind !== "subagent");
129
+ if (input.operation === "create") {
130
+ if (active)
131
+ throw new Error("subagents cannot create task definitions");
132
+ return definitions.create(input.task, `session:${callerSessionId}`);
133
+ }
134
+ if (input.operation === "update") {
135
+ if (active)
136
+ throw new Error("subagents cannot update task definitions");
137
+ return definitions.update(requiredString(input.task_id, "task_id"), input.task);
138
+ }
139
+ if (input.operation === "run") {
140
+ if (active && active.context.definition.action.type === "agent" && active.context.definition.action.launch?.capabilities === "read") {
141
+ throw new Error("read-only subagents cannot delegate nested work");
142
+ }
143
+ if (Array.isArray(input.tasks)) {
144
+ // Core-joined fan-out: members run detached, one aggregated callback.
145
+ if (input.task !== undefined || input.task_id !== undefined)
146
+ throw new Error("use either task/task_id or tasks[]");
147
+ if (input.session_mode !== undefined)
148
+ throw new Error("session_mode applies to a single run only");
149
+ if (input.tasks.length < 2)
150
+ throw new Error("tasks[] needs at least 2 entries; use task for a single run");
151
+ const resolved = [];
152
+ for (const rawEntry of input.tasks) {
153
+ const entry = record(rawEntry);
154
+ if (!entry)
155
+ throw new Error("invalid tasks[] entry");
156
+ resolved.push(entry.task_id === undefined
157
+ ? await resolveDraft(definitions, entry, active, callerSessionId)
158
+ : resolveStored(definitions, entry.task_id, active));
159
+ }
160
+ const { group, runs } = host.runGroup(resolved, input.join === "first" ? "first" : "all", callerSessionId, active?.id ?? null, input.callback === "none" ? null : callerSessionId);
161
+ return summarizeGroup(group, runs, messages);
162
+ }
163
+ const draft = input.task_id === undefined ? record(input.task) : undefined;
164
+ const task = draft
165
+ ? await resolveDraft(definitions, draft, active, callerSessionId)
166
+ : resolveStored(definitions, input.task_id, active);
167
+ const sessionMode = input.session_mode === "fresh" || input.session_mode === "fork" ? input.session_mode : undefined;
168
+ let callbackSessionId = input.callback === "none" ? null : callerSessionId;
169
+ if (!active && callbackSessionId && typeof input.callback_session_id === "string") {
170
+ callbackSessionId = requiredString(input.callback_session_id, "callback_session_id");
171
+ if (!(await definitions.sessionExists(callbackSessionId)))
172
+ throw new Error(`unknown session: ${callbackSessionId}`);
173
+ }
174
+ const run = host.run(task.id, input.input, "agent", active?.id ?? null, {
175
+ invokedBySessionId: callerSessionId,
176
+ sourceSessionId: callerSessionId,
177
+ callbackSessionId,
178
+ background: true,
179
+ sessionMode,
180
+ });
181
+ return summarize(run, null);
182
+ }
183
+ if (input.operation === "get") {
184
+ if (typeof input.group_id === "string") {
185
+ const { group, members } = host.getGroup(input.group_id);
186
+ return summarizeGroup(group, members, messages);
187
+ }
188
+ // Run history by task: without it, checking what a task did (or whether a
189
+ // cascade landed) means leaving the tool for the database.
190
+ if (input.run_id === undefined && typeof input.task_id === "string") {
191
+ return host.listRuns(input.task_id, 10).map((run) => summarize(run, messages.openDecisionId(run.id)));
192
+ }
193
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
194
+ return summarize(run, messages.openDecisionId(run.id));
195
+ }
196
+ if (input.operation === "cancel") {
197
+ if (typeof input.group_id === "string") {
198
+ const { group, members } = host.getGroup(input.group_id);
199
+ for (const member of members)
200
+ assertOwns(store, callerSessionId, active, member);
201
+ const cancelled = host.cancelGroup(group.id);
202
+ return summarizeGroup(cancelled, cancelled.memberRunIds.map((id) => host.getRun(id)), messages);
203
+ }
204
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
205
+ assertOwns(store, callerSessionId, active, run);
206
+ const cancelled = host.cancel(run.id);
207
+ return summarize(cancelled, messages.openDecisionId(cancelled.id));
208
+ }
209
+ if (input.operation === "steer" || input.operation === "follow_up") {
210
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
211
+ assertOwns(store, callerSessionId, active, run);
212
+ return host.control(run.id, callerSessionId, input.operation, requiredString(input.message, "message"));
213
+ }
214
+ if (input.operation === "resume") {
215
+ const prior = host.getRun(requiredString(input.run_id, "run_id"));
216
+ assertOwns(store, callerSessionId, active, prior);
217
+ const run = host.resume(prior.id, requiredString(input.message, "message"), {
218
+ invokedBySessionId: callerSessionId,
219
+ callbackSessionId: input.callback === "none" ? null : callerSessionId,
220
+ background: true,
221
+ });
222
+ return summarize(run, null);
223
+ }
224
+ if (input.operation === "contact") {
225
+ if (!active)
226
+ throw new Error("contact is only available inside an active Agent run");
227
+ const reason = input.reason === "decision" ? "decision" : "progress";
228
+ return messages.contact(active, callerSessionId, reason, requiredString(input.message, "message"));
229
+ }
230
+ if (input.operation === "reply") {
231
+ return messages.reply(requiredString(input.message_id, "message_id"), callerSessionId, requiredString(input.message, "message"));
232
+ }
233
+ throw new Error("unknown task operation");
234
+ }
235
+ /** Inline one-shot subagent: persisted like any task (kind "subagent",
236
+ * filtered from default lists) so runs stay auditable and resumable. */
237
+ async function resolveDraft(definitions, draft, active, callerSessionId) {
238
+ if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
239
+ throw new Error("inline subagent tasks must use a manual trigger");
240
+ }
241
+ // The draft parser accepts `capabilities` for Console/HTTP definitions; from
242
+ // the tool it is rejected rather than silently honoured, so a model working
243
+ // from stale memory learns the field is gone.
244
+ if (record(record(draft.action)?.launch)?.capabilities !== undefined) {
245
+ throw new Error("launch.capabilities is configured in Console or HTTP, not by the task tool");
246
+ }
247
+ if (active) {
248
+ const action = record(draft.action);
249
+ if (action?.type !== "agent")
250
+ throw new Error("subagents may only inline Agent tasks");
251
+ if (record(action.session)?.mode === "reuse")
252
+ throw new Error("subagent inline tasks cannot reuse an existing session");
253
+ }
254
+ return definitions.create({ ...draft, trigger: { type: "manual" } }, `session:${callerSessionId}`, "subagent");
255
+ }
256
+ function resolveStored(definitions, taskId, active) {
257
+ const task = definitions.get(requiredString(taskId, "task_id"));
258
+ if (active && task.action.type !== "agent")
259
+ throw new Error("subagents may only invoke Agent tasks");
260
+ return task;
261
+ }
262
+ function assertOwns(store, callerSessionId, active, target) {
263
+ if (active) {
264
+ let cursor = target;
265
+ while (cursor?.parentRunId) {
266
+ if (cursor.parentRunId === active.id)
267
+ return;
268
+ cursor = store.getRun(cursor.parentRunId);
269
+ }
270
+ throw new Error("subagent may only control descendant runs");
271
+ }
272
+ let root = target;
273
+ while (root.parentRunId) {
274
+ const parent = store.getRun(root.parentRunId);
275
+ if (!parent)
276
+ throw new Error(`unknown parent run: ${root.parentRunId}`);
277
+ root = parent;
278
+ }
279
+ if (root.invokedBySessionId !== callerSessionId)
280
+ throw new Error("session does not own this run");
281
+ }
@@ -0,0 +1,5 @@
1
+ export const isTerminal = (state) => state === "succeeded" ||
2
+ state === "failed" ||
3
+ state === "cancelled" ||
4
+ state === "interrupted" ||
5
+ state === "skipped";
@@ -0,0 +1,280 @@
1
+ // The boundary in front of every HTTP surface: one shared password.
2
+ //
3
+ // Single-account on purpose. Pier has one workspace, so there is nobody to
4
+ // tell apart — an internet-facing deployment needs a *boundary*, not
5
+ // identities. Multiple people using it share the page, and share the password.
6
+ //
7
+ // Nothing to configure before first run: the store generates a password on an
8
+ // empty database, keeps only its scrypt hash, and prints the plaintext once to
9
+ // the log. There is no window where the port is open and unclaimed — the
10
+ // password exists before the listener does — and no env var for an operator to
11
+ // get wrong. Forgot it? Delete the row and restart; a new one is printed.
12
+ //
13
+ // The cookie is a signed expiry, not a stored session id: an HMAC keyed by the
14
+ // stored hash. No session table, no pruning — and changing the password
15
+ // changes the key, so every cookie already out there dies with it. That is the
16
+ // whole revocation story a single-user system needs. A cookie (not a bearer
17
+ // header) because the workbench lives on SSE, and EventSource sends no headers.
18
+ import { createHash, createHmac, randomBytes, randomInt, scryptSync, timingSafeEqual, } from "node:crypto";
19
+ import { getCookie, setCookie } from "hono/cookie";
20
+ import { pierDb } from "../db.js";
21
+ import { logger } from "../log.js";
22
+ const log = logger("auth");
23
+ const COOKIE = "pier_session";
24
+ const TTL_MS = 90 * 24 * 60 * 60_000;
25
+ /** Failed attempts one client may make before it has to wait out the window. */
26
+ const MAX_FAILURES = 10;
27
+ const WINDOW_MS = 15 * 60_000;
28
+ /** Shortest password a human may choose. The generated one is longer; this is
29
+ * the floor under which the throttle above stops being enough. */
30
+ const MIN_LENGTH = 10;
31
+ // scrypt at Node's defaults (N=16384): ~50ms per attempt, which is the point.
32
+ const KEY_BYTES = 32;
33
+ /**
34
+ * Human-readable and unambiguous: no 0/O, 1/l/I, so it survives being read off
35
+ * a terminal and typed into a phone. 15 characters from a 31-symbol alphabet is
36
+ * ~74 bits — this is the only thing between the internet and a shell.
37
+ *
38
+ * `randomInt` rejection-samples. Folding a random byte with `% 31` would have
39
+ * quietly favoured the first eight symbols, which is the kind of bias nothing
40
+ * ever reports.
41
+ */
42
+ function generatePassword() {
43
+ const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
44
+ const chars = Array.from({ length: 15 }, () => alphabet[randomInt(alphabet.length)]).join("");
45
+ return `${chars.slice(0, 5)}-${chars.slice(5, 10)}-${chars.slice(10)}`;
46
+ }
47
+ /**
48
+ * The stored credential: one row, one password, hashed.
49
+ *
50
+ * Generation happens in the constructor because "no password" is not a state
51
+ * Pier may ever serve in — a boot that cannot print the password it just made
52
+ * should fail at boot, not open a door.
53
+ */
54
+ export class AuthStore {
55
+ #db;
56
+ /** HMAC key for cookies: the hash, so rotating the password expires them. */
57
+ #key;
58
+ constructor(db = pierDb(), print = (m) => log.info(m)) {
59
+ this.#db = db;
60
+ let row = this.#row();
61
+ if (!row) {
62
+ const password = generatePassword();
63
+ const salt = randomBytes(16).toString("hex");
64
+ row = { salt, hash: hash(password, salt), createdAt: Date.now() };
65
+ this.#db
66
+ .prepare("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
67
+ .run(row.salt, row.hash, row.createdAt);
68
+ print(`\nthis instance had no password, so one was generated:\n\n ${password}\n\n` +
69
+ `only its hash is stored — it is not printed again. ` +
70
+ `Lost it? "DELETE FROM auth" in the database, then restart.\n`);
71
+ }
72
+ this.#key = row.hash;
73
+ }
74
+ #row() {
75
+ return this.#db
76
+ .prepare("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
77
+ .get();
78
+ }
79
+ /** Whether this is the password, compared in constant time. */
80
+ verify(password) {
81
+ const row = this.#row();
82
+ return row ? sameSecret(hash(password, row.salt), row.hash) : false;
83
+ }
84
+ /**
85
+ * Replace the password, salt and all.
86
+ *
87
+ * The new hash becomes the cookie key, so every cookie signed with the old
88
+ * one — every other browser, and the caller's own — stops verifying. That is
89
+ * the point: a password is changed because the old one may be known.
90
+ */
91
+ setPassword(password) {
92
+ const salt = randomBytes(16).toString("hex");
93
+ const next = hash(password, salt);
94
+ this.#db
95
+ .prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
96
+ .run(salt, next, Date.now());
97
+ this.#key = next;
98
+ }
99
+ /** Cookie signing key. Never the password: that is not stored anywhere. */
100
+ get cookieKey() {
101
+ return this.#key;
102
+ }
103
+ }
104
+ const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString("hex");
105
+ /**
106
+ * What a logged-out visitor must still reach: the login form, published
107
+ * boards, and the stylesheet those boards link — a published board rendering
108
+ * unstyled for the person it was published to is the same bug as not serving
109
+ * it. `/boards/*` stays behind the boundary; `/p/*` is the published mirror,
110
+ * the single exempt prefix `docs/architecture.md` reserved for this.
111
+ */
112
+ function isPublic(path) {
113
+ return (path === "/login" ||
114
+ path === "/p" ||
115
+ path.startsWith("/p/") ||
116
+ path === "/boards/_assets/pier.css");
117
+ }
118
+ const sign = (secret, expiresAt) => createHmac("sha256", secret).update(String(expiresAt)).digest("base64url");
119
+ /** Constant-time equality that also hides length: both sides are digested. */
120
+ function sameSecret(a, b) {
121
+ const digest = (s) => createHash("sha256").update(s).digest();
122
+ return timingSafeEqual(digest(a), digest(b));
123
+ }
124
+ function valid(secret, cookie) {
125
+ const [exp, sig] = (cookie ?? "").split(".");
126
+ const expiresAt = Number(exp);
127
+ if (!sig || !Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())
128
+ return false;
129
+ return sameSecret(sig, sign(secret, expiresAt));
130
+ }
131
+ /**
132
+ * Only a same-origin path may be returned to after login. `//evil.example` is a
133
+ * protocol-relative URL rather than a path, and browsers normalize a backslash
134
+ * to a slash, so `/\evil.example` is the same trick spelled differently — both
135
+ * are what a `startsWith("/")` check alone hands an open redirect to.
136
+ */
137
+ const safeNext = (raw) => typeof raw === "string" && /^\/(?![/\\])/.test(raw) ? raw : "/";
138
+ // Failed logins per client, in memory: a restart clearing them is fine, since
139
+ // the window is minutes and the point is to make guessing slow, not to keep
140
+ // books. Bounded by pruning every expired entry on each check.
141
+ const failures = new Map();
142
+ /**
143
+ * Behind a reverse proxy every request shares one socket address, so the
144
+ * forwarded hop is the only thing separating two clients. It is spoofable when
145
+ * Pier is exposed directly — that is an argument for the proxy, not against
146
+ * the limit: a password is the thing being protected here, the counter only
147
+ * decides how fast someone may guess.
148
+ */
149
+ function clientOf(c) {
150
+ return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "local";
151
+ }
152
+ function throttled(client) {
153
+ const now = Date.now();
154
+ for (const [id, entry] of failures)
155
+ if (entry.resetAt <= now)
156
+ failures.delete(id);
157
+ return (failures.get(client)?.count ?? 0) >= MAX_FAILURES;
158
+ }
159
+ function noteFailure(client) {
160
+ const entry = failures.get(client);
161
+ if (entry && entry.resetAt > Date.now())
162
+ entry.count += 1;
163
+ else
164
+ failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
165
+ }
166
+ /** Every route, in one place — no per-route opt-in to forget on the next one. */
167
+ export function requireAuth(store) {
168
+ return async (c, next) => {
169
+ if (isPublic(c.req.path) || valid(store.cookieKey, getCookie(c, COOKIE)))
170
+ return next();
171
+ // An API caller gets a status it can act on; a navigation gets the form.
172
+ // Anything non-GET is a client call too — never a link worth redirecting.
173
+ if (c.req.path.startsWith("/api/") || c.req.method !== "GET") {
174
+ return c.json({ error: "unauthorized" }, 401);
175
+ }
176
+ return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`);
177
+ };
178
+ }
179
+ export function registerAuthRoutes(app, store) {
180
+ app.get("/login", (c) => c.html(loginPage(safeNext(c.req.query("next")))));
181
+ app.post("/login", async (c) => {
182
+ const client = clientOf(c);
183
+ const form = await c.req.parseBody();
184
+ const next = safeNext(form.next);
185
+ if (throttled(client)) {
186
+ // The one surface strangers can reach: a burst here is the only warning
187
+ // an operator gets that the port is being knocked on.
188
+ log.warn(`login throttled for ${client}`);
189
+ return c.html(loginPage(next, "Too many attempts. Wait a few minutes."), 429);
190
+ }
191
+ if (!store.verify(typeof form.password === "string" ? form.password : "")) {
192
+ noteFailure(client);
193
+ log.warn(`wrong password from ${client}`);
194
+ return c.html(loginPage(next, "Wrong password."), 401);
195
+ }
196
+ failures.delete(client);
197
+ log.info(`login from ${client}`);
198
+ issueCookie(c, store);
199
+ return c.redirect(next);
200
+ });
201
+ // Changing the password is a login — it takes the current one, and it is
202
+ // throttled by the same counter, because "change" answers the same guess
203
+ // "sign in" does. So it needs no cookie of its own: whoever knows the current
204
+ // password can already get one.
205
+ app.post("/api/password", async (c) => {
206
+ const client = clientOf(c);
207
+ const body = (await c.req.json().catch(() => null));
208
+ const current = typeof body?.current === "string" ? body.current : "";
209
+ const next = typeof body?.next === "string" ? body.next : "";
210
+ if (throttled(client))
211
+ return c.json({ error: "Too many attempts. Wait a few minutes." }, 429);
212
+ if (!store.verify(current)) {
213
+ noteFailure(client);
214
+ return c.json({ error: "Wrong current password." }, 401);
215
+ }
216
+ if (next.length < MIN_LENGTH) {
217
+ return c.json({ error: `Use at least ${MIN_LENGTH} characters.` }, 400);
218
+ }
219
+ failures.delete(client);
220
+ store.setPassword(next);
221
+ // The rotation just killed this caller's cookie too; re-issue rather than
222
+ // bounce the person who is holding the new password to the login form.
223
+ issueCookie(c, store);
224
+ return c.json({ ok: true });
225
+ });
226
+ }
227
+ /** The signed-in cookie, set the same way by login and by a password change. */
228
+ function issueCookie(c, store) {
229
+ const expiresAt = Date.now() + TTL_MS;
230
+ setCookie(c, COOKIE, `${expiresAt}.${sign(store.cookieKey, expiresAt)}`, {
231
+ path: "/",
232
+ httpOnly: true,
233
+ sameSite: "Lax",
234
+ // Set only over TLS: a Secure cookie on plain http is dropped, which
235
+ // would lock out the loopback and SSH-tunnel setups.
236
+ secure: c.req.header("x-forwarded-proto") === "https" ||
237
+ new URL(c.req.url).protocol === "https:",
238
+ maxAge: TTL_MS / 1000,
239
+ });
240
+ }
241
+ /**
242
+ * Self-contained HTML: the login page must render before the workbench bundle
243
+ * is reachable, so it links nothing the boundary would refuse to serve.
244
+ */
245
+ function loginPage(next, error) {
246
+ const attr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
247
+ return `<!doctype html>
248
+ <html lang="en">
249
+ <head>
250
+ <meta charset="utf-8" />
251
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
252
+ <meta name="theme-color" content="#fafafa" />
253
+ <title>Pier</title>
254
+ <style>
255
+ :root { color-scheme: light }
256
+ body { margin: 0; height: 100dvh; display: grid; place-items: center; background: #fafafa;
257
+ color: #262626; font: 16px/1.45 ui-sans-serif, system-ui, sans-serif }
258
+ form { display: grid; gap: .75rem; width: min(20rem, 88vw) }
259
+ h1 { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: .01em }
260
+ input { padding: .5rem .625rem; font-size: 1rem; color: inherit; background: #fff;
261
+ border: 1px solid #d4d4d4; border-radius: .5rem }
262
+ input:focus { outline: 2px solid #a3a3a3; outline-offset: -1px }
263
+ button { padding: .5rem; font: inherit; font-weight: 500; color: #fafafa; background: #262626;
264
+ border: 0; border-radius: .5rem; cursor: pointer }
265
+ p { margin: 0; font-size: .8125rem; color: #dc2626 }
266
+ </style>
267
+ </head>
268
+ <body>
269
+ <form method="post" action="/login">
270
+ <h1>Pier</h1>
271
+ ${error ? `<p>${attr(error)}</p>` : ""}
272
+ <input type="password" name="password" placeholder="Password" autocomplete="current-password"
273
+ autofocus required />
274
+ <input type="hidden" name="next" value="${attr(next)}" />
275
+ <button type="submit">Sign in</button>
276
+ </form>
277
+ </body>
278
+ </html>
279
+ `;
280
+ }