hanoman 0.1.22 → 0.1.24

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.22",
3
- "sha": "c3a0a34",
4
- "builtAt": "2026-08-08T07:24:55.380Z"
2
+ "version": "0.1.24",
3
+ "sha": "3024ec0",
4
+ "builtAt": "2026-08-10T03:28:41.335Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -4986,7 +4986,7 @@ var init_prd_status = __esm({
4986
4986
  });
4987
4987
 
4988
4988
  // ../shared/src/dto.ts
4989
- var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, zLogin, zSignup, zChangePassword, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
4989
+ var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
4990
4990
  var init_dto = __esm({
4991
4991
  "../shared/src/dto.ts"() {
4992
4992
  "use strict";
@@ -5400,12 +5400,24 @@ var init_dto = __esm({
5400
5400
  password: external_exports.string().min(1)
5401
5401
  // SPEC-165 · diisi = bootstrap ulang
5402
5402
  }).partial();
5403
+ USER_ROLES = ["admin", "client"];
5404
+ zUserRole = external_exports.enum(USER_ROLES);
5403
5405
  zLogin = external_exports.object({ email: external_exports.string().email(), password: external_exports.string().min(1) });
5404
5406
  zSignup = external_exports.object({ email: external_exports.string().email(), password: external_exports.string().min(8) });
5405
5407
  zChangePassword = external_exports.object({
5406
5408
  currentPassword: external_exports.string().min(1),
5407
5409
  newPassword: external_exports.string().min(8)
5408
5410
  });
5411
+ zCreateClientAccount = external_exports.object({
5412
+ email: external_exports.string().email(),
5413
+ password: external_exports.string().min(8),
5414
+ projects: external_exports.array(external_exports.string().min(1)).default([])
5415
+ });
5416
+ zUpdateClientAccount = external_exports.object({
5417
+ projects: external_exports.array(external_exports.string().min(1)),
5418
+ disabled: external_exports.boolean(),
5419
+ password: external_exports.string().min(8)
5420
+ }).partial();
5409
5421
  zVpsCheck = external_exports.object({
5410
5422
  check: external_exports.string(),
5411
5423
  status: external_exports.enum(["pass", "fail", "warn", "na"]),
@@ -5741,6 +5753,14 @@ var init_ticket_status = __esm({
5741
5753
  }
5742
5754
  });
5743
5755
 
5756
+ // ../shared/src/portal.ts
5757
+ var init_portal = __esm({
5758
+ "../shared/src/portal.ts"() {
5759
+ "use strict";
5760
+ init_ticket_status();
5761
+ }
5762
+ });
5763
+
5744
5764
  // ../shared/src/session-kind.ts
5745
5765
  var SESSION_KINDS, zSessionKind;
5746
5766
  var init_session_kind = __esm({
@@ -7022,6 +7042,7 @@ var init_src = __esm({
7022
7042
  init_api();
7023
7043
  init_coverage();
7024
7044
  init_ticket_status();
7045
+ init_portal();
7025
7046
  init_prd_status();
7026
7047
  init_session_kind();
7027
7048
  init_config();
@@ -28591,7 +28612,9 @@ var init_migrate_pg = __esm({
28591
28612
  // SPEC-516 · ADR-0105 · Changelog sesudah Project (FK projectId). Tabel ini LOCAL-only dan
28592
28613
  // lazimnya TIDAK ada di sumber Postgres lama — jalur 42P01 memperlakukannya sebagai nol baris.
28593
28614
  "Changelog",
28615
+ // SPEC-617 · ADR-0110 · ClientProjectAccess sesudah User DAN Project (FK ke keduanya).
28594
28616
  "User",
28617
+ "ClientProjectAccess",
28595
28618
  "Session",
28596
28619
  "DeviceToken",
28597
28620
  "AgentToken",
package/dist/server.js CHANGED
@@ -5237,7 +5237,7 @@ var init_prd_status = __esm({
5237
5237
  function flowForSource(source) {
5238
5238
  return source === "qa" ? "qa" : source === "audit" ? "audit" : source === "goal" ? "goal" : "feature";
5239
5239
  }
5240
- var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, zLogin, zSignup, zChangePassword, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
5240
+ var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
5241
5241
  var init_dto = __esm({
5242
5242
  "../shared/src/dto.ts"() {
5243
5243
  "use strict";
@@ -5651,12 +5651,24 @@ var init_dto = __esm({
5651
5651
  password: external_exports.string().min(1)
5652
5652
  // SPEC-165 · diisi = bootstrap ulang
5653
5653
  }).partial();
5654
+ USER_ROLES = ["admin", "client"];
5655
+ zUserRole = external_exports.enum(USER_ROLES);
5654
5656
  zLogin = external_exports.object({ email: external_exports.string().email(), password: external_exports.string().min(1) });
5655
5657
  zSignup = external_exports.object({ email: external_exports.string().email(), password: external_exports.string().min(8) });
5656
5658
  zChangePassword = external_exports.object({
5657
5659
  currentPassword: external_exports.string().min(1),
5658
5660
  newPassword: external_exports.string().min(8)
5659
5661
  });
5662
+ zCreateClientAccount = external_exports.object({
5663
+ email: external_exports.string().email(),
5664
+ password: external_exports.string().min(8),
5665
+ projects: external_exports.array(external_exports.string().min(1)).default([])
5666
+ });
5667
+ zUpdateClientAccount = external_exports.object({
5668
+ projects: external_exports.array(external_exports.string().min(1)),
5669
+ disabled: external_exports.boolean(),
5670
+ password: external_exports.string().min(8)
5671
+ }).partial();
5660
5672
  zVpsCheck = external_exports.object({
5661
5673
  check: external_exports.string(),
5662
5674
  status: external_exports.enum(["pass", "fail", "warn", "na"]),
@@ -6002,6 +6014,44 @@ var init_ticket_status = __esm({
6002
6014
  }
6003
6015
  });
6004
6016
 
6017
+ // ../shared/src/portal.ts
6018
+ function toPortalProject(p) {
6019
+ return { id: p.id, name: p.name };
6020
+ }
6021
+ function toPortalSpec(s2) {
6022
+ return {
6023
+ id: s2.id,
6024
+ title: s2.title,
6025
+ priority: s2.priority,
6026
+ stage: s2.stage,
6027
+ objective: s2.objective,
6028
+ createdAt: iso(s2.createdAt),
6029
+ startedAt: iso(s2.startedAt),
6030
+ doneAt: iso(s2.doneAt)
6031
+ };
6032
+ }
6033
+ function toPortalTicket(t, specStage) {
6034
+ return {
6035
+ id: t.id,
6036
+ number: t.number,
6037
+ category: t.category,
6038
+ title: t.title,
6039
+ status: publicStatus(t.status, specStage),
6040
+ createdAt: iso(t.createdAt)
6041
+ };
6042
+ }
6043
+ function toPortalTicketDetail(t, specStage) {
6044
+ return { ...toPortalTicket(t, specStage), detail: t.detail };
6045
+ }
6046
+ var iso;
6047
+ var init_portal = __esm({
6048
+ "../shared/src/portal.ts"() {
6049
+ "use strict";
6050
+ init_ticket_status();
6051
+ iso = (d) => d == null ? null : typeof d === "string" ? d : d.toISOString();
6052
+ }
6053
+ });
6054
+
6005
6055
  // ../shared/src/session-kind.ts
6006
6056
  var SESSION_KINDS, zSessionKind;
6007
6057
  var init_session_kind = __esm({
@@ -6315,14 +6365,14 @@ function matchesEvent(subscribed, type) {
6315
6365
  }
6316
6366
  function projectRow(def2, row) {
6317
6367
  const out3 = {};
6318
- for (const f of def2.fields) if (f in row) out3[f] = iso(row[f]);
6368
+ for (const f of def2.fields) if (f in row) out3[f] = iso2(row[f]);
6319
6369
  return out3;
6320
6370
  }
6321
6371
  function diffFields(def2, before, after) {
6322
6372
  const out3 = [];
6323
6373
  for (const f of def2.fields) {
6324
6374
  if (IGNORED_FIELDS.has(f)) continue;
6325
- if (JSON.stringify(iso(before[f]) ?? null) !== JSON.stringify(iso(after[f]) ?? null)) out3.push(f);
6375
+ if (JSON.stringify(iso2(before[f]) ?? null) !== JSON.stringify(iso2(after[f]) ?? null)) out3.push(f);
6326
6376
  }
6327
6377
  return out3;
6328
6378
  }
@@ -6361,7 +6411,7 @@ function clampEnvelope(env) {
6361
6411
  }
6362
6412
  return { ...env, truncated: true, truncatedFields: fields };
6363
6413
  }
6364
- var WEBHOOK_SPEC_VERSION, WEBHOOK_BACKOFF_SEC, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_FAIL_LIMIT, WEBHOOK_QUEUE_CAP, WEBHOOK_HISTORY_KEEP, WEBHOOK_MAX_BYTES, WEBHOOK_FIELD_MAX_CHARS, WEBHOOK_TIMEOUT_MS, WEBHOOK_DEFAULT_PER_MINUTE, WEBHOOK_PING_TYPE, WEBHOOK_USER_AGENT, WEBHOOK_HEADERS, SYSTEM_ACTOR, IGNORED_FIELDS, WEBHOOK_ENTITIES, BY_MODEL, WEBHOOK_EVENTS, webhookEventTypes, iso, zCreateWebhookEndpoint, zUpdateWebhookEndpoint;
6414
+ var WEBHOOK_SPEC_VERSION, WEBHOOK_BACKOFF_SEC, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_FAIL_LIMIT, WEBHOOK_QUEUE_CAP, WEBHOOK_HISTORY_KEEP, WEBHOOK_MAX_BYTES, WEBHOOK_FIELD_MAX_CHARS, WEBHOOK_TIMEOUT_MS, WEBHOOK_DEFAULT_PER_MINUTE, WEBHOOK_PING_TYPE, WEBHOOK_USER_AGENT, WEBHOOK_HEADERS, SYSTEM_ACTOR, IGNORED_FIELDS, WEBHOOK_ENTITIES, BY_MODEL, WEBHOOK_EVENTS, webhookEventTypes, iso2, zCreateWebhookEndpoint, zUpdateWebhookEndpoint;
6365
6415
  var init_webhook = __esm({
6366
6416
  "../shared/src/webhook.ts"() {
6367
6417
  "use strict";
@@ -6718,7 +6768,7 @@ var init_webhook = __esm({
6718
6768
  }
6719
6769
  ];
6720
6770
  webhookEventTypes = () => WEBHOOK_EVENTS.map((e) => e.type);
6721
- iso = (v) => v instanceof Date ? v.toISOString() : v;
6771
+ iso2 = (v) => v instanceof Date ? v.toISOString() : v;
6722
6772
  zCreateWebhookEndpoint = external_exports.object({
6723
6773
  name: external_exports.string().trim().min(1).max(80),
6724
6774
  url: external_exports.string().trim().min(1).max(2e3),
@@ -7381,6 +7431,7 @@ var init_src = __esm({
7381
7431
  init_api();
7382
7432
  init_coverage();
7383
7433
  init_ticket_status();
7434
+ init_portal();
7384
7435
  init_prd_status();
7385
7436
  init_session_kind();
7386
7437
  init_config();
@@ -29986,10 +30037,85 @@ async function webhooks_default(app2) {
29986
30037
  });
29987
30038
  }
29988
30039
 
29989
- // src/app.ts
29990
- var import_multipart = __toESM(require_multipart2(), 1);
30040
+ // src/routes/portal.ts
30041
+ init_src();
30042
+ init_db();
29991
30043
 
29992
- // src/routes/auth.ts
30044
+ // src/services/client-access.ts
30045
+ init_db();
30046
+ var segments = (path) => path.replace(/^\/api\/?/, "").replace(/\/+$/, "").split("/").filter(Boolean);
30047
+ function clientRouteAllowed(method, path) {
30048
+ const seg = segments(path);
30049
+ if (seg.includes("..")) return false;
30050
+ const top = seg[0] ?? "";
30051
+ const read = method === "GET" || method === "HEAD";
30052
+ if (top === "portal") return read;
30053
+ if (top === "help") return true;
30054
+ if (top === "auth") {
30055
+ const sub = seg[1] ?? "";
30056
+ return method === "POST" && (sub === "logout" || sub === "change-password");
30057
+ }
30058
+ return false;
30059
+ }
30060
+ async function clientProjectIds(userId) {
30061
+ const rows = await prisma.clientProjectAccess.findMany({
30062
+ where: { userId },
30063
+ select: { projectId: true },
30064
+ orderBy: { projectId: "asc" }
30065
+ });
30066
+ return rows.map((r) => r.projectId);
30067
+ }
30068
+ async function hasProjectAccess(userId, projectId) {
30069
+ return await prisma.clientProjectAccess.count({ where: { userId, projectId } }) > 0;
30070
+ }
30071
+
30072
+ // src/routes/portal.ts
30073
+ var NOT_FOUND = { error: "not found" };
30074
+ async function specStages(ids) {
30075
+ const wanted = [...new Set(ids.filter((v) => !!v))];
30076
+ if (!wanted.length) return /* @__PURE__ */ new Map();
30077
+ const rows = await prisma.spec.findMany({ where: { id: { in: wanted } }, select: { id: true, stage: true } });
30078
+ return new Map(rows.map((r) => [r.id, r.stage]));
30079
+ }
30080
+ async function portal_default(app2) {
30081
+ app2.get("/portal/projects", async (req) => {
30082
+ const ids = await clientProjectIds(req.user.id);
30083
+ const rows = await prisma.project.findMany({ where: { id: { in: ids } }, orderBy: { name: "asc" } });
30084
+ return { items: rows.map(toPortalProject) };
30085
+ });
30086
+ app2.get("/portal/projects/:id/backlog", async (req, reply) => {
30087
+ const { id } = req.params;
30088
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30089
+ const { page, limit } = req.query;
30090
+ const specs = await liveSpecs({ project: id });
30091
+ return paginate(specs.map(toPortalSpec), page, limit);
30092
+ });
30093
+ app2.get("/portal/projects/:id/backlog/:specId", async (req, reply) => {
30094
+ const { id, specId } = req.params;
30095
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30096
+ const spec = (await liveSpecs({ project: id })).find((s2) => s2.id === specId);
30097
+ if (!spec) return reply.code(404).send(NOT_FOUND);
30098
+ return toPortalSpec(spec);
30099
+ });
30100
+ app2.get("/portal/projects/:id/tickets", async (req, reply) => {
30101
+ const { id } = req.params;
30102
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30103
+ const { page, limit } = req.query;
30104
+ const rows = await prisma.ticket.findMany({ where: { projectId: id }, orderBy: { createdAt: "desc" } });
30105
+ const stages = await specStages(rows.map((t) => t.specId));
30106
+ return paginate(rows.map((t) => toPortalTicket(t, stages.get(t.specId ?? "") ?? null)), page, limit);
30107
+ });
30108
+ app2.get("/portal/projects/:id/tickets/:ticketId", async (req, reply) => {
30109
+ const { id, ticketId } = req.params;
30110
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30111
+ const t = await prisma.ticket.findUnique({ where: { id: ticketId } });
30112
+ if (!t || t.projectId !== id) return reply.code(404).send(NOT_FOUND);
30113
+ const stages = await specStages([t.specId]);
30114
+ return toPortalTicketDetail(t, stages.get(t.specId ?? "") ?? null);
30115
+ });
30116
+ }
30117
+
30118
+ // src/routes/client-accounts.ts
29993
30119
  init_src();
29994
30120
  init_db();
29995
30121
 
@@ -30029,7 +30155,13 @@ async function lookupSession(token) {
30029
30155
  });
30030
30156
  return null;
30031
30157
  }
30032
- return { id: s2.user.id, email: s2.user.email, createdAt: s2.user.createdAt.toISOString() };
30158
+ if (s2.user.disabled) return null;
30159
+ return {
30160
+ id: s2.user.id,
30161
+ email: s2.user.email,
30162
+ role: s2.user.role,
30163
+ createdAt: s2.user.createdAt.toISOString()
30164
+ };
30033
30165
  }
30034
30166
  async function deleteSession(token) {
30035
30167
  await prisma.session.deleteMany({ where: { id: sessionId(token) } });
@@ -30066,8 +30198,83 @@ function cookieOpts() {
30066
30198
  };
30067
30199
  }
30068
30200
 
30201
+ // src/routes/client-accounts.ts
30202
+ var view9 = (u) => ({
30203
+ id: u.id,
30204
+ email: u.email,
30205
+ disabled: u.disabled,
30206
+ createdAt: u.createdAt.toISOString(),
30207
+ projects: u.projectAccess.map((a) => a.projectId).sort()
30208
+ });
30209
+ var load = (id) => prisma.user.findFirst({
30210
+ where: { id, role: "client" },
30211
+ include: { projectAccess: { select: { projectId: true } } }
30212
+ });
30213
+ async function setAccess(userId, projects) {
30214
+ const ids = [...new Set(projects)];
30215
+ const known = await prisma.project.findMany({ where: { id: { in: ids } }, select: { id: true } });
30216
+ if (known.length !== ids.length) return null;
30217
+ await prisma.clientProjectAccess.deleteMany({ where: { userId, projectId: { notIn: ids } } });
30218
+ for (const projectId of ids)
30219
+ await prisma.clientProjectAccess.upsert({
30220
+ where: { userId_projectId: { userId, projectId } },
30221
+ update: {},
30222
+ create: { userId, projectId }
30223
+ });
30224
+ return ids;
30225
+ }
30226
+ async function client_accounts_default(app2) {
30227
+ app2.get("/client-accounts", async () => {
30228
+ const rows = await prisma.user.findMany({
30229
+ where: { role: "client" },
30230
+ orderBy: { createdAt: "asc" },
30231
+ include: { projectAccess: { select: { projectId: true } } }
30232
+ });
30233
+ return { items: rows.map(view9) };
30234
+ });
30235
+ app2.post("/client-accounts", async (req, reply) => {
30236
+ const p = zCreateClientAccount.safeParse(req.body);
30237
+ if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30238
+ if (await prisma.user.findUnique({ where: { email: p.data.email } }))
30239
+ return reply.code(409).send({ error: "email dipakai" });
30240
+ const user = await prisma.user.create({
30241
+ data: { email: p.data.email, passwordHash: await hashPassword(p.data.password), role: "client" }
30242
+ });
30243
+ if (await setAccess(user.id, p.data.projects) === null) {
30244
+ await prisma.user.delete({ where: { id: user.id } });
30245
+ return reply.code(400).send({ error: "ada project yang tidak dikenal" });
30246
+ }
30247
+ return reply.code(201).send(view9(await load(user.id)));
30248
+ });
30249
+ app2.patch("/client-accounts/:id", async (req, reply) => {
30250
+ const { id } = req.params;
30251
+ const p = zUpdateClientAccount.safeParse(req.body);
30252
+ if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30253
+ if (!await load(id)) return reply.code(404).send({ error: "not found" });
30254
+ if (p.data.projects && await setAccess(id, p.data.projects) === null)
30255
+ return reply.code(400).send({ error: "ada project yang tidak dikenal" });
30256
+ if (p.data.disabled !== void 0)
30257
+ await prisma.user.update({ where: { id }, data: { disabled: p.data.disabled } });
30258
+ if (p.data.password)
30259
+ await prisma.user.update({ where: { id }, data: { passwordHash: await hashPassword(p.data.password) } });
30260
+ if (p.data.disabled || p.data.password) await deleteUserSessions(id);
30261
+ return view9(await load(id));
30262
+ });
30263
+ app2.delete("/client-accounts/:id", async (req, reply) => {
30264
+ const { id } = req.params;
30265
+ if (!await load(id)) return reply.code(404).send({ error: "not found" });
30266
+ await prisma.user.delete({ where: { id } });
30267
+ return reply.code(204).send();
30268
+ });
30269
+ }
30270
+
30271
+ // src/app.ts
30272
+ var import_multipart = __toESM(require_multipart2(), 1);
30273
+
30069
30274
  // src/routes/auth.ts
30070
- var view9 = (u) => ({ id: u.id, email: u.email, createdAt: u.createdAt.toISOString() });
30275
+ init_src();
30276
+ init_db();
30277
+ var view10 = (u) => ({ id: u.id, email: u.email, role: u.role, createdAt: u.createdAt.toISOString() });
30071
30278
  async function issue(reply, userId) {
30072
30279
  const token = await createSession2(userId);
30073
30280
  reply.setCookie(COOKIE_NAME, token, cookieOpts());
@@ -30085,20 +30292,20 @@ async function auth_default(app2) {
30085
30292
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
30086
30293
  });
30087
30294
  await issue(reply, user.id);
30088
- return { user: view9(user) };
30295
+ return { user: view10(user) };
30089
30296
  });
30090
30297
  app2.post("/auth/login", async (req, reply) => {
30091
30298
  if (loginThrottle(req.ip).blocked) return reply.code(429).send({ error: "too many attempts" });
30092
30299
  const p = zLogin.safeParse(req.body);
30093
30300
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30094
30301
  const user = await prisma.user.findUnique({ where: { email: p.data.email } });
30095
- if (!user || !await verifyPassword(p.data.password, user.passwordHash)) {
30302
+ if (!user || user.disabled || !await verifyPassword(p.data.password, user.passwordHash)) {
30096
30303
  noteLoginFail(req.ip);
30097
30304
  return reply.code(401).send({ error: "email atau password salah" });
30098
30305
  }
30099
30306
  clearLoginFails(req.ip);
30100
30307
  await issue(reply, user.id);
30101
- return { user: view9(user) };
30308
+ return { user: view10(user) };
30102
30309
  });
30103
30310
  app2.post("/auth/logout", async (req, reply) => {
30104
30311
  const token = req.cookies?.[COOKIE_NAME];
@@ -30106,19 +30313,24 @@ async function auth_default(app2) {
30106
30313
  reply.clearCookie(COOKIE_NAME, { path: "/" });
30107
30314
  return reply.code(204).send();
30108
30315
  });
30109
- app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view9));
30316
+ app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view10));
30110
30317
  app2.post("/auth/users", async (req, reply) => {
30111
30318
  const p = zSignup.safeParse(req.body);
30112
30319
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30113
30320
  if (await prisma.user.findUnique({ where: { email: p.data.email } }))
30114
30321
  return reply.code(409).send({ error: "email dipakai" });
30115
30322
  const user = await prisma.user.create({
30116
- data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
30323
+ // SPEC-617 · pintu ini melahirkan OPERATOR. Akun klien punya pintunya sendiri
30324
+ // (/api/client-accounts) supaya "undang rekan" dan "beri akses klien" tak pernah tertukar.
30325
+ data: { email: p.data.email, passwordHash: await hashPassword(p.data.password), role: "admin" }
30117
30326
  });
30118
- return view9(user);
30327
+ return view10(user);
30119
30328
  });
30120
30329
  app2.delete("/auth/users/:id", async (req, reply) => {
30121
- if (await prisma.user.count() <= 1) return reply.code(400).send({ error: "tak bisa hapus user terakhir" });
30330
+ const target2 = await prisma.user.findUnique({ where: { id: req.params.id } });
30331
+ if (!target2) return reply.code(204).send();
30332
+ if (target2.role === "admin" && await prisma.user.count({ where: { role: "admin" } }) <= 1)
30333
+ return reply.code(400).send({ error: "tak bisa hapus admin terakhir" });
30122
30334
  await prisma.user.deleteMany({ where: { id: req.params.id } });
30123
30335
  return reply.code(204).send();
30124
30336
  });
@@ -30135,7 +30347,7 @@ async function auth_default(app2) {
30135
30347
  });
30136
30348
  await deleteUserSessions(user.id);
30137
30349
  await issue(reply, user.id);
30138
- return { user: view9(user) };
30350
+ return { user: view10(user) };
30139
30351
  });
30140
30352
  }
30141
30353
 
@@ -30148,15 +30360,15 @@ async function agent_tokens_default(app2) {
30148
30360
  app2.post("/agent-tokens", async (req, reply) => {
30149
30361
  const p = zAgentTokenCreate.safeParse(req.body);
30150
30362
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30151
- const { view: view10, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
30152
- return reply.code(201).send({ ...view10, token });
30363
+ const { view: view11, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
30364
+ return reply.code(201).send({ ...view11, token });
30153
30365
  });
30154
30366
  app2.patch("/agent-tokens/:id", async (req, reply) => {
30155
30367
  const p = zAgentTokenPatch.safeParse(req.body);
30156
30368
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30157
30369
  const { id } = req.params;
30158
- const view10 = await patchAgentToken(id, p.data);
30159
- return view10 ? reply.send(view10) : reply.code(404).send({ error: "not found" });
30370
+ const view11 = await patchAgentToken(id, p.data);
30371
+ return view11 ? reply.send(view11) : reply.code(404).send({ error: "not found" });
30160
30372
  });
30161
30373
  app2.delete("/agent-tokens/:id", async (req, reply) => {
30162
30374
  const { id } = req.params;
@@ -30201,7 +30413,7 @@ function capabilityForRoute(method, path) {
30201
30413
  const rw = (d) => `${d}:${read ? "read" : "write"}`;
30202
30414
  const seg = path.replace(/^\/api\/?/, "").replace(/\/+$/, "").split("/").filter(Boolean);
30203
30415
  const top = seg[0] ?? "";
30204
- if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync") return "COOKIE_ONLY";
30416
+ if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync" || top === "portal" || top === "client-accounts") return "COOKIE_ONLY";
30205
30417
  if (top === "limits" || top === "update" || top === "events" || top === "fs" || top === "health")
30206
30418
  return read ? "GLOBAL_READ" : "COOKIE_ONLY";
30207
30419
  if (top === "scheduler") return rw("settings");
@@ -30400,6 +30612,8 @@ function buildApp({ requireAuth = true, agentDocFile } = {}) {
30400
30612
  if (user) req.user = user;
30401
30613
  const path = req.url.split("?")[0] ?? req.url;
30402
30614
  if (PUBLIC.has(`${req.method} ${path}`)) return;
30615
+ if (user?.role === "client" && !clientRouteAllowed(req.method, path))
30616
+ return reply.code(403).send({ error: "portal klien: baca-saja" });
30403
30617
  if (path.startsWith("/api/sync") && path !== "/api/sync/now" && !path.startsWith("/api/sync/conflicts")) return;
30404
30618
  if (path.startsWith("/api/help")) return;
30405
30619
  if (user) return;
@@ -30455,6 +30669,8 @@ function buildApp({ requireAuth = true, agentDocFile } = {}) {
30455
30669
  await api.register(telegramRoutes);
30456
30670
  await api.register(webhooks_default);
30457
30671
  await api.register(changelog_default);
30672
+ await api.register(portal_default);
30673
+ await api.register(client_accounts_default);
30458
30674
  }, { prefix: "/api" });
30459
30675
  if (process.env.NODE_ENV === "production") {
30460
30676
  const dist = pickWebDir(dirname13(fileURLToPath4(import.meta.url)), process.env, existsSync13);
@@ -145,6 +145,10 @@ token selalu **403**, apa pun capability-nya, dan tak ada capability yang bisa m
145
145
  mengalir keluar (ADR-0100)
146
146
  - `/api/telegram/settings`, `/api/telegram/test`, `/api/telegram/credentials` — permukaan
147
147
  **kredensial** (bot token & agent token), beda dari sisa `/api/telegram*` (ADR-0097)
148
+ - `/api/portal*` — portal klien: isinya ditentukan **akun yang login** (`ClientProjectAccess`), jadi
149
+ tak ada capability yang bisa berarti apa pun di sana. Backlog & tiket yang sama tersedia lewat
150
+ `/api/specs` dan `/api/tickets` dengan capability `backlog`/`support` (ADR-0110)
151
+ - `/api/client-accounts*` — membuat & mencabut akun klien beserta password awalnya (ADR-0110)
148
152
  - `POST /api/update/apply` dan tulis lain di bawah prefix status (`/api/limits`, `/api/update`,
149
153
  `/api/events`, `/api/fs`, `/api/health`) — **baca**-nya terbuka untuk token mana pun, **tulis**-nya
150
154
  cookie-only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,15 @@
1
+ -- SPEC-617 · ADR-0110 · peran user + pemetaan akun klien → project.
2
+ -- Additif & aman untuk DB yang sudah berjalan: user lama mendapat role 'admin' lewat DEFAULT,
3
+ -- jadi tak ada akses yang putus. (SQLite hanya melarang DEFAULT CURRENT_TIMESTAMP.)
4
+ ALTER TABLE "User" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'admin';
5
+ ALTER TABLE "User" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false;
6
+
7
+ CREATE TABLE "ClientProjectAccess" (
8
+ "id" TEXT NOT NULL PRIMARY KEY,
9
+ "userId" TEXT NOT NULL,
10
+ "projectId" TEXT NOT NULL,
11
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+ CONSTRAINT "ClientProjectAccess_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
13
+ CONSTRAINT "ClientProjectAccess_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
14
+ );
15
+ CREATE UNIQUE INDEX "ClientProjectAccess_userId_projectId_key" ON "ClientProjectAccess"("userId", "projectId");
@@ -29,6 +29,7 @@ model Project {
29
29
  customAgents CustomAgent[] // SPEC-450 · ADR-0094 · custom agent milik project ini
30
30
  githubIssues GithubIssue[] // SPEC-471 · ADR-0095 · cermin issue repo GitHub project ini
31
31
  changelogs Changelog[] // SPEC-516 · ADR-0105 · changelog tersimpan project ini
32
+ clientAccess ClientProjectAccess[] // SPEC-617 · ADR-0110 · akun klien yang boleh melihat project ini
32
33
  }
33
34
 
34
35
  model Spec {
@@ -188,14 +189,37 @@ model Notification {
188
189
  readAt DateTime?
189
190
  }
190
191
 
191
- // SPEC-169 · auth. passwordHash = scrypt "<saltHex>:<hashHex>". Tak ada RBAC — semua user setara.
192
+ // SPEC-169 · auth. passwordHash = scrypt "<saltHex>:<hashHex>".
193
+ // SPEC-617 · ADR-0110 · dua peran: `admin` (operator, akses penuh seperti sebelumnya) dan
194
+ // `client` (portal baca-saja ber-scope project). Default `"admin"` DISENGAJA: itulah yang
195
+ // membuat migrasi aman untuk instance yang sudah berjalan — setiap baris lama otomatis admin,
196
+ // tak ada akses yang putus dan tak ada backfill manual.
192
197
  model User {
193
198
  id String @id @default(cuid())
194
199
  email String @unique
195
200
  passwordHash String
201
+ role String @default("admin")
202
+ // SPEC-617 · nonaktif ditolak di login DAN di lookupSession; hanya menutup login berarti
203
+ // cookie yang sudah terbit tetap hidup sampai 7 hari (pencabutan yang tak mencabut).
204
+ disabled Boolean @default(false)
196
205
  createdAt DateTime @default(now())
197
206
  sessions Session[]
198
207
  deviceTokens DeviceToken[]
208
+ projectAccess ClientProjectAccess[]
209
+ }
210
+
211
+ // SPEC-617 · ADR-0110 · project yang boleh dilihat sebuah akun klien. Tabel join, bukan kolom
212
+ // Json: SQLite melarang scalar list, dan relasi memberi cascade delete gratis di kedua sisi.
213
+ // LOCAL-only — cermin User/Session/AgentToken, tak pernah masuk SYNCED (services/sync.ts).
214
+ model ClientProjectAccess {
215
+ id String @id @default(cuid())
216
+ userId String
217
+ projectId String
218
+ createdAt DateTime @default(now())
219
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
220
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
221
+
222
+ @@unique([userId, projectId])
199
223
  }
200
224
 
201
225
  // SPEC-169 · sesi opaque revocable. id = sha256(token); token asli hanya ada di cookie httpOnly.
@@ -1,4 +1,4 @@
1
- @import"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Serif:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap";:root{--ink-900: #17130c;--ink-800: #241d13;--ink-700: #3a3125;--ink-600: #574b3a;--ink-500: #6f6250;--ink-400: #90826c;--ink-300: #b3a794;--ink-200: #d6ccb9;--bone-000: #fffdf8;--bone-100: #faf6ec;--bone-200: #f2ebdc;--bone-300: #e8dfcc;--bone-400: #dccfb6;--brass-700: #7a5417;--brass-600: #97671f;--brass-500: #b8863b;--brass-400: #cf9d55;--brass-300: #e3c988;--brass-100: #f3e6c4;--brass-050: #faf2df;--wind-700: #2f5560;--wind-600: #3f6e7a;--wind-500: #517f8c;--wind-100: #dbe7ea;--wind-050: #eef4f5;--leaf-600: #4d6b30;--leaf-500: #5f8340;--leaf-100: #e6eed7;--amber-600: #b3771a;--amber-500: #cf8f28;--amber-100: #f7e9c9;--clay-600: #a23b2e;--clay-500: #bd4a3a;--clay-100: #f5dcd6;--term-bg: #1c1810;--term-fg: #e9e0cd;--term-dim: #8f8467;--term-line: #2c2618;--surface-page: var(--bone-100);--surface-panel: var(--bone-200);--surface-card: var(--bone-000);--surface-sunken: var(--bone-200);--surface-inverse: var(--ink-900);--surface-code: var(--term-bg);--text-strong: var(--ink-900);--text-body: var(--ink-700);--text-muted: var(--ink-500);--text-subtle: var(--ink-400);--text-on-brass: var(--ink-900);--text-on-dark: var(--term-fg);--text-link: var(--wind-600);--border-hair: var(--bone-300);--border-strong: var(--bone-400);--border-focus: var(--brass-500);--accent: var(--brass-500);--accent-hover: var(--brass-600);--accent-press: var(--brass-700);--accent-tint: var(--brass-100);--accent-on: var(--ink-900);--info: var(--wind-600);--info-tint: var(--wind-100);--status-ok: var(--leaf-600);--status-ok-tint: var(--leaf-100);--status-warn: var(--amber-600);--status-warn-tint: var(--amber-100);--status-err: var(--clay-600);--status-err-tint: var(--clay-100);--ring: 0 0 0 3px color-mix(in srgb, var(--brass-500) 45%, transparent)}:root{--font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;--font-display: var(--font-serif);--font-body: var(--font-sans);--font-ui: var(--font-sans);--font-code: var(--font-mono);--text-2xs: 11px;--text-xs: 12px;--text-sm: 13px;--text-md: 14px;--text-base:16px;--text-lg: 18px;--text-xl: 20px;--text-2xl: 24px;--text-3xl: 30px;--text-4xl: 38px;--text-5xl: 48px;--text-6xl: 62px;--weight-regular: 400;--weight-medium: 500;--weight-semibold: 600;--weight-bold: 700;--leading-tight: 1.12;--leading-snug: 1.3;--leading-normal: 1.5;--leading-relaxed:1.65;--tracking-tight: -.02em;--tracking-normal: 0;--tracking-wide: .02em;--tracking-caps: .14em}:root{--space-0: 0;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 5px;--radius-md: 8px;--radius-lg: 12px;--radius-xl: 16px;--radius-pill: 999px;--border-thin: 1px;--border-med: 1.5px;--border-thick:2px;--content-max: 1200px;--sidebar-w: 248px;--topbar-h: 56px}:root{--shadow-xs: 0 1px 1px rgba(35, 27, 15, .05);--shadow-sm: 0 1px 2px rgba(35, 27, 15, .06), 0 1px 1px rgba(35, 27, 15, .04);--shadow-md: 0 2px 4px rgba(35, 27, 15, .07), 0 4px 12px rgba(35, 27, 15, .06);--shadow-lg: 0 8px 24px rgba(35, 27, 15, .1), 0 2px 6px rgba(35, 27, 15, .06);--shadow-xl: 0 20px 48px rgba(35, 27, 15, .16), 0 4px 12px rgba(35, 27, 15, .08);--shadow-inset: inset 0 1px 2px rgba(35, 27, 15, .08);--shadow-gild: 0 1px 0 rgba(255, 253, 248, .6) inset, 0 0 0 1px var(--brass-300);--elevation-flat: none;--elevation-raised: var(--shadow-sm);--elevation-float: var(--shadow-md);--elevation-overlay:var(--shadow-lg);--elevation-modal: var(--shadow-xl);--ease-out: cubic-bezier(.22, .61, .36, 1);--ease-inout: cubic-bezier(.65, 0, .35, 1);--dur-fast: .12s;--dur-base: .18s;--dur-slow: .28s;--transition-base: all var(--dur-base) var(--ease-out);--transition-fast: all var(--dur-fast) var(--ease-out)}*,*:before,*:after{box-sizing:border-box}body{margin:0;background:var(--surface-page);color:var(--text-body);font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-normal);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}h1,h2,h3,h4{font-family:var(--font-display);color:var(--text-strong);line-height:var(--leading-tight);letter-spacing:var(--tracking-tight);font-weight:var(--weight-semibold);margin:0 0 var(--space-3)}p{margin:0 0 var(--space-3)}a{color:var(--text-link);text-decoration:none;border-bottom:1px solid color-mix(in srgb,var(--text-link) 35%,transparent);transition:var(--transition-fast)}a:hover{color:var(--wind-700);border-bottom-color:var(--wind-700)}code,kbd,samp,pre{font-family:var(--font-code);font-size:.92em}.hn-eyebrow{font-family:var(--font-mono);font-size:var(--text-2xs);font-weight:var(--weight-medium);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}::selection{background:var(--brass-100);color:var(--ink-900)}html,body{margin:0;height:100%;background:var(--surface-page)}#root{height:100vh;overflow:hidden}button{--accent-on: #fff}a{color:var(--text-link);text-decoration:none}a:hover{color:var(--wind-700);text-decoration:underline}.hn-md{color:var(--text-body);font-family:var(--font-sans);font-size:15px;line-height:1.72;overflow-wrap:anywhere}.hn-md>:first-child{margin-top:8px}.hn-md h1,.hn-md h2,.hn-md h3,.hn-md h4{font-family:var(--font-display);font-weight:600;letter-spacing:-.02em;color:var(--ink-900);line-height:1.2;margin:1.6em 0 .5em}.hn-md h1{font-size:30px;margin-top:.4em;padding-bottom:.34em;border-bottom:1px solid var(--border-hair)}.hn-md h2{font-size:22px}.hn-md h3{font-size:18px}.hn-md h4{font-size:15px}.hn-md p{margin:.7em 0}.hn-md ul,.hn-md ol{margin:.6em 0;padding-left:1.5em}.hn-md li{margin:.28em 0}.hn-md li::marker{color:var(--brass-500)}.hn-md ul.contains-task-list,.hn-md li.task-list-item{list-style:none}.hn-md li.task-list-item{padding-left:0}.hn-md input[type=checkbox]{accent-color:var(--brass-500);margin-right:8px;transform:translateY(1px)}.hn-md strong{font-weight:600;color:var(--ink-900)}.hn-md em{color:var(--text-muted)}.hn-md a{color:var(--text-link)}.hn-md hr{border:none;border-top:1px solid var(--border-hair);margin:1.6em 0}.hn-md code{font-family:var(--font-mono);font-size:.86em;background:var(--bone-200);color:var(--ink-800);padding:1.5px 5px;border-radius:var(--radius-xs);border:1px solid var(--border-hair)}.hn-md pre{background:var(--surface-code);border:1px solid var(--term-line);border-radius:var(--radius-lg);padding:14px 16px;overflow:auto;margin:1em 0;white-space:pre-wrap;overflow-wrap:anywhere}.hn-md pre code{background:transparent;border:none;padding:0;color:var(--term-fg);font-size:12.5px;line-height:1.7}.hn-md blockquote{margin:1em 0;padding:4px 16px;border-left:3px solid var(--brass-500);background:var(--brass-050);color:var(--text-body);border-radius:0 var(--radius-sm) var(--radius-sm) 0}.hn-md blockquote p{margin:.4em 0}.hn-md table{border-collapse:collapse;width:100%;margin:1em 0;font-size:14px;table-layout:fixed}.hn-md th,.hn-md td{overflow-wrap:anywhere}.hn-md th{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);text-align:left;padding:8px 12px;background:var(--bone-100);border:1px solid var(--border-hair)}.hn-md td{padding:8px 12px;border:1px solid var(--border-hair);color:var(--text-body)}.hn-md td code{font-size:.82em}.hn-md img{max-width:100%;border-radius:var(--radius-sm)}@keyframes hn-fade-in{0%{opacity:0}to{opacity:1}}@keyframes hn-modal-in{0%{opacity:0;transform:translateY(8px) scale(.99)}to{opacity:1;transform:none}}@keyframes hn-toast-in{0%{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%)}}/**
1
+ @import"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Serif:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap";:root{--ink-900: #17130c;--ink-800: #241d13;--ink-700: #3a3125;--ink-600: #574b3a;--ink-500: #6f6250;--ink-400: #90826c;--ink-300: #b3a794;--ink-200: #d6ccb9;--bone-000: #fffdf8;--bone-100: #faf6ec;--bone-200: #f2ebdc;--bone-300: #e8dfcc;--bone-400: #dccfb6;--brass-700: #7a5417;--brass-600: #97671f;--brass-500: #b8863b;--brass-400: #cf9d55;--brass-300: #e3c988;--brass-100: #f3e6c4;--brass-050: #faf2df;--wind-700: #2f5560;--wind-600: #3f6e7a;--wind-500: #517f8c;--wind-100: #dbe7ea;--wind-050: #eef4f5;--leaf-600: #4d6b30;--leaf-500: #5f8340;--leaf-100: #e6eed7;--amber-600: #b3771a;--amber-500: #cf8f28;--amber-100: #f7e9c9;--clay-600: #a23b2e;--clay-500: #bd4a3a;--clay-100: #f5dcd6;--term-bg: #1c1810;--term-fg: #e9e0cd;--term-dim: #8f8467;--term-line: #2c2618;--surface-page: var(--bone-100);--surface-panel: var(--bone-200);--surface-card: var(--bone-000);--surface-sunken: var(--bone-200);--surface-inverse: var(--ink-900);--surface-code: var(--term-bg);--text-strong: var(--ink-900);--text-body: var(--ink-700);--text-muted: var(--ink-500);--text-subtle: var(--ink-400);--text-on-brass: var(--ink-900);--text-on-dark: var(--term-fg);--text-link: var(--wind-600);--border-hair: var(--bone-300);--border-strong: var(--bone-400);--border-focus: var(--brass-500);--accent: var(--brass-500);--accent-hover: var(--brass-600);--accent-press: var(--brass-700);--accent-tint: var(--brass-100);--accent-on: var(--ink-900);--info: var(--wind-600);--info-tint: var(--wind-100);--status-ok: var(--leaf-600);--status-ok-tint: var(--leaf-100);--status-warn: var(--amber-600);--status-warn-tint: var(--amber-100);--status-err: var(--clay-600);--status-err-tint: var(--clay-100);--ring: 0 0 0 3px color-mix(in srgb, var(--brass-500) 45%, transparent)}:root{--font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;--font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;--font-display: var(--font-serif);--font-body: var(--font-sans);--font-ui: var(--font-sans);--font-code: var(--font-mono);--text-2xs: 11px;--text-xs: 12px;--text-sm: 13px;--text-md: 14px;--text-base:16px;--text-lg: 18px;--text-xl: 20px;--text-2xl: 24px;--text-3xl: 30px;--text-4xl: 38px;--text-5xl: 48px;--text-6xl: 62px;--weight-regular: 400;--weight-medium: 500;--weight-semibold: 600;--weight-bold: 700;--leading-tight: 1.12;--leading-snug: 1.3;--leading-normal: 1.5;--leading-relaxed:1.65;--tracking-tight: -.02em;--tracking-normal: 0;--tracking-wide: .02em;--tracking-caps: .14em}:root{--space-0: 0;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 5px;--radius-md: 8px;--radius-lg: 12px;--radius-xl: 16px;--radius-pill: 999px;--border-thin: 1px;--border-med: 1.5px;--border-thick:2px;--content-max: 1200px;--sidebar-w: 248px;--topbar-h: 56px}:root{--shadow-xs: 0 1px 1px rgba(35, 27, 15, .05);--shadow-sm: 0 1px 2px rgba(35, 27, 15, .06), 0 1px 1px rgba(35, 27, 15, .04);--shadow-md: 0 2px 4px rgba(35, 27, 15, .07), 0 4px 12px rgba(35, 27, 15, .06);--shadow-lg: 0 8px 24px rgba(35, 27, 15, .1), 0 2px 6px rgba(35, 27, 15, .06);--shadow-xl: 0 20px 48px rgba(35, 27, 15, .16), 0 4px 12px rgba(35, 27, 15, .08);--shadow-inset: inset 0 1px 2px rgba(35, 27, 15, .08);--shadow-gild: 0 1px 0 rgba(255, 253, 248, .6) inset, 0 0 0 1px var(--brass-300);--elevation-flat: none;--elevation-raised: var(--shadow-sm);--elevation-float: var(--shadow-md);--elevation-overlay:var(--shadow-lg);--elevation-modal: var(--shadow-xl);--ease-out: cubic-bezier(.22, .61, .36, 1);--ease-inout: cubic-bezier(.65, 0, .35, 1);--dur-fast: .12s;--dur-base: .18s;--dur-slow: .28s;--transition-base: all var(--dur-base) var(--ease-out);--transition-fast: all var(--dur-fast) var(--ease-out)}*,*:before,*:after{box-sizing:border-box}body{margin:0;background:var(--surface-page);color:var(--text-body);font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-normal);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}h1,h2,h3,h4{font-family:var(--font-display);color:var(--text-strong);line-height:var(--leading-tight);letter-spacing:var(--tracking-tight);font-weight:var(--weight-semibold);margin:0 0 var(--space-3)}p{margin:0 0 var(--space-3)}a{color:var(--text-link);text-decoration:none;border-bottom:1px solid color-mix(in srgb,var(--text-link) 35%,transparent);transition:var(--transition-fast)}a:hover{color:var(--wind-700);border-bottom-color:var(--wind-700)}code,kbd,samp,pre{font-family:var(--font-code);font-size:.92em}.hn-eyebrow{font-family:var(--font-mono);font-size:var(--text-2xs);font-weight:var(--weight-medium);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}::selection{background:var(--brass-100);color:var(--ink-900)}html,body{margin:0;height:100%;background:var(--surface-page)}#root{height:100vh;overflow:hidden}button{--accent-on: #fff}a{color:var(--text-link);text-decoration:none}a:hover{color:var(--wind-700);text-decoration:underline}.hn-md{color:var(--text-body);font-family:var(--font-sans);font-size:15px;line-height:1.72;overflow-wrap:anywhere}.hn-md>:first-child{margin-top:8px}.hn-md h1,.hn-md h2,.hn-md h3,.hn-md h4{font-family:var(--font-display);font-weight:600;letter-spacing:-.02em;color:var(--ink-900);line-height:1.2;margin:1.6em 0 .5em}.hn-md h1{font-size:30px;margin-top:.4em;padding-bottom:.34em;border-bottom:1px solid var(--border-hair)}.hn-md h2{font-size:22px}.hn-md h3{font-size:18px}.hn-md h4{font-size:15px}.hn-md p{margin:.7em 0}.hn-md ul,.hn-md ol{margin:.6em 0;padding-left:1.5em}.hn-md li{margin:.28em 0}.hn-md li::marker{color:var(--brass-500)}.hn-md ul.contains-task-list,.hn-md li.task-list-item{list-style:none}.hn-md li.task-list-item{padding-left:0}.hn-md input[type=checkbox]{accent-color:var(--brass-500);margin-right:8px;transform:translateY(1px)}.hn-md strong{font-weight:600;color:var(--ink-900)}.hn-md em{color:var(--text-muted)}.hn-md a{color:var(--text-link)}.hn-md hr{border:none;border-top:1px solid var(--border-hair);margin:1.6em 0}.hn-md code{font-family:var(--font-mono);font-size:.86em;background:var(--bone-200);color:var(--ink-800);padding:1.5px 5px;border-radius:var(--radius-xs);border:1px solid var(--border-hair)}.hn-md pre{background:var(--surface-code);border:1px solid var(--term-line);border-radius:var(--radius-lg);padding:14px 16px;overflow:auto;margin:1em 0;white-space:pre-wrap;overflow-wrap:anywhere}.hn-md pre code{background:transparent;border:none;padding:0;color:var(--term-fg);font-size:12.5px;line-height:1.7}.hn-md blockquote{margin:1em 0;padding:4px 16px;border-left:3px solid var(--brass-500);background:var(--brass-050);color:var(--text-body);border-radius:0 var(--radius-sm) var(--radius-sm) 0}.hn-md blockquote p{margin:.4em 0}.hn-md table{border-collapse:collapse;width:100%;margin:1em 0;font-size:14px;table-layout:fixed}.hn-md th,.hn-md td{overflow-wrap:anywhere}.hn-md th{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);text-align:left;padding:8px 12px;background:var(--bone-100);border:1px solid var(--border-hair)}.hn-md td{padding:8px 12px;border:1px solid var(--border-hair);color:var(--text-body)}.hn-md td code{font-size:.82em}.hn-md img{max-width:100%;border-radius:var(--radius-sm)}@keyframes hn-fade-in{0%{opacity:0}to{opacity:1}}@keyframes hn-modal-in{0%{opacity:0;transform:translateY(8px) scale(.99)}to{opacity:1;transform:none}}@keyframes hn-toast-in{0%{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%)}}@keyframes hn-pet-breathe{0%{transform:translateY(0) scale(1)}to{transform:translateY(-3px) scale(1.025)}}/**
2
2
  * Copyright (c) 2014 The xterm.js authors. All rights reserved.
3
3
  * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
4
4
  * https://github.com/chjj/term.js