hanoman 0.1.23 → 0.1.25

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.23",
3
- "sha": "eea3d70",
4
- "builtAt": "2026-08-08T09:16:09.486Z"
2
+ "version": "0.1.25",
3
+ "sha": "53d6464",
4
+ "builtAt": "2026-08-10T04:26:46.836Z"
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();
@@ -26043,10 +26094,64 @@ async function pruneOldTickets(now = Date.now()) {
26043
26094
  }
26044
26095
  }
26045
26096
 
26097
+ // src/services/ticket-intake.ts
26098
+ init_db();
26099
+ var TICKET_UPLOAD = {
26100
+ MAX_FILES: 3,
26101
+ MAX_BYTES: 5 * 1024 * 1024,
26102
+ OK_MIME: /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"])
26103
+ };
26104
+ async function parseTicketUpload(req) {
26105
+ const fields = {};
26106
+ const files = [];
26107
+ try {
26108
+ for await (const part of req.parts()) {
26109
+ if (part.type === "file") {
26110
+ const buf = await part.toBuffer();
26111
+ if (part.file?.truncated || !TICKET_UPLOAD.OK_MIME.has(part.mimetype) || files.length >= TICKET_UPLOAD.MAX_FILES) continue;
26112
+ files.push({ buf, mime: part.mimetype, name: String(part.filename ?? "gambar") });
26113
+ } else {
26114
+ fields[part.fieldname] = String(part.value ?? "");
26115
+ }
26116
+ }
26117
+ } catch {
26118
+ return null;
26119
+ }
26120
+ return { fields, files };
26121
+ }
26122
+ async function intakeTicket(input) {
26123
+ const { ticket, key } = await createTicket({
26124
+ projectId: input.projectId,
26125
+ category: input.category,
26126
+ title: input.title,
26127
+ detail: input.detail,
26128
+ reporterEmail: input.reporterEmail
26129
+ });
26130
+ await notifySynced("ticket", ticket.id);
26131
+ for (const f of input.files) {
26132
+ const { storageKey, size } = await saveUpload(f.buf, f.mime);
26133
+ const att = await prisma.ticketAttachment.create({
26134
+ data: {
26135
+ ticketId: ticket.id,
26136
+ projectId: input.projectId,
26137
+ filename: f.name.slice(0, 200),
26138
+ mimeType: f.mime,
26139
+ size,
26140
+ storageKey
26141
+ }
26142
+ });
26143
+ await notifySynced("ticketAttachment", att.id);
26144
+ }
26145
+ await recordNewTicket(ticket.id, input.projectId, input.projectName, input.category, input.title);
26146
+ void pruneOldTickets();
26147
+ return { ticket, key };
26148
+ }
26149
+
26046
26150
  // src/services/help-ratelimit.ts
26047
26151
  init_config2();
26048
26152
  var ipBuckets = /* @__PURE__ */ new Map();
26049
26153
  var projBuckets = /* @__PURE__ */ new Map();
26154
+ var acctBuckets = /* @__PURE__ */ new Map();
26050
26155
  function take(map, k, cap, now) {
26051
26156
  const b = map.get(k) ?? { tokens: cap, ts: now };
26052
26157
  b.tokens = Math.min(cap, b.tokens + (now - b.ts) * cap / 6e4);
@@ -26064,13 +26169,15 @@ function helpRateOk(projectId, ip, now = Date.now()) {
26064
26169
  const projCap = effectiveInt("HANOMAN_HELP_RATE_PER_MIN_PROJECT") ?? 20;
26065
26170
  return take(ipBuckets, ip, ipCap, now) && take(projBuckets, projectId, projCap, now);
26066
26171
  }
26172
+ function portalTicketRateOk(userId, projectId, now = Date.now()) {
26173
+ const acctCap = effectiveInt("HANOMAN_PORTAL_TICKET_RATE_PER_MIN") ?? 5;
26174
+ const projCap = effectiveInt("HANOMAN_HELP_RATE_PER_MIN_PROJECT") ?? 20;
26175
+ return take(acctBuckets, userId, acctCap, now) && take(projBuckets, projectId, projCap, now);
26176
+ }
26067
26177
 
26068
26178
  // src/routes/help.ts
26069
26179
  var CATEGORIES = ["bug", "fitur", "pertanyaan", "lainnya"];
26070
26180
  var HONEYPOT_FIELD = "hc_trap";
26071
- var OK_MIME = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
26072
- var MAX_FILES2 = 3;
26073
- var MAX_BYTES = 5 * 1024 * 1024;
26074
26181
  var zField = external_exports.object({
26075
26182
  category: zTicketCategory,
26076
26183
  title: external_exports.string().min(1).max(200),
@@ -26090,21 +26197,9 @@ async function help_default(app2) {
26090
26197
  if (!p || !p.helpEnabled) return reply.code(404).send({ error: "not found" });
26091
26198
  if (!helpRateOk(slug, req.ip)) return reply.code(429).send({ error: "terlalu banyak permintaan" });
26092
26199
  if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
26093
- const fields = {};
26094
- const files = [];
26095
- try {
26096
- for await (const part of req.parts()) {
26097
- if (part.type === "file") {
26098
- const buf = await part.toBuffer();
26099
- if (part.file?.truncated || !OK_MIME.has(part.mimetype) || files.length >= MAX_FILES2) continue;
26100
- files.push({ buf, mime: part.mimetype, name: String(part.filename ?? "gambar") });
26101
- } else {
26102
- fields[part.fieldname] = String(part.value ?? "");
26103
- }
26104
- }
26105
- } catch {
26106
- return reply.code(400).send({ error: "unggahan tak valid" });
26107
- }
26200
+ const upload = await parseTicketUpload(req);
26201
+ if (!upload) return reply.code(400).send({ error: "unggahan tak valid" });
26202
+ const { fields, files } = upload;
26108
26203
  if (fields[HONEYPOT_FIELD]) {
26109
26204
  console.warn(`help: honeypot terpicu \xB7 project=${slug} ip=${req.ip}`);
26110
26205
  return reply.code(200).send({ ok: true });
@@ -26116,23 +26211,15 @@ async function help_default(app2) {
26116
26211
  email: fields.email
26117
26212
  });
26118
26213
  if (!parsed.success) return reply.code(400).send({ error: "field wajib tak lengkap / tak valid" });
26119
- const { ticket, key } = await createTicket({
26214
+ const { ticket, key } = await intakeTicket({
26120
26215
  projectId: slug,
26216
+ projectName: p.name,
26121
26217
  category: parsed.data.category,
26122
26218
  title: parsed.data.title,
26123
26219
  detail: parsed.data.detail,
26124
- reporterEmail: parsed.data.email
26220
+ reporterEmail: parsed.data.email,
26221
+ files
26125
26222
  });
26126
- await notifySynced("ticket", ticket.id);
26127
- for (const f of files) {
26128
- const { storageKey, size } = await saveUpload(f.buf, f.mime);
26129
- const att = await prisma.ticketAttachment.create({
26130
- data: { ticketId: ticket.id, projectId: slug, filename: f.name.slice(0, 200), mimeType: f.mime, size, storageKey }
26131
- });
26132
- await notifySynced("ticketAttachment", att.id);
26133
- }
26134
- await recordNewTicket(ticket.id, slug, p.name, parsed.data.category, parsed.data.title);
26135
- void pruneOldTickets();
26136
26223
  const statusPath = `/help/${encodeURIComponent(slug)}/status/${encodeURIComponent(key)}`;
26137
26224
  return reply.code(201).send({ number: ticket.number, key, statusPath });
26138
26225
  });
@@ -29986,10 +30073,117 @@ async function webhooks_default(app2) {
29986
30073
  });
29987
30074
  }
29988
30075
 
29989
- // src/app.ts
29990
- var import_multipart = __toESM(require_multipart2(), 1);
30076
+ // src/routes/portal.ts
30077
+ init_zod();
30078
+ init_src();
30079
+ init_db();
29991
30080
 
29992
- // src/routes/auth.ts
30081
+ // src/services/client-access.ts
30082
+ init_db();
30083
+ var segments = (path) => path.replace(/^\/api\/?/, "").replace(/\/+$/, "").split("/").filter(Boolean);
30084
+ var isPortalTicketSubmit = (method, seg) => method === "POST" && seg.length === 4 && seg[1] === "projects" && seg[3] === "tickets";
30085
+ function clientRouteAllowed(method, path) {
30086
+ const seg = segments(path);
30087
+ if (seg.includes("..")) return false;
30088
+ const top = seg[0] ?? "";
30089
+ const read = method === "GET" || method === "HEAD";
30090
+ if (top === "portal") return read || isPortalTicketSubmit(method, seg);
30091
+ if (top === "help") return true;
30092
+ if (top === "auth") {
30093
+ const sub = seg[1] ?? "";
30094
+ return method === "POST" && (sub === "logout" || sub === "change-password");
30095
+ }
30096
+ return false;
30097
+ }
30098
+ async function clientProjectIds(userId) {
30099
+ const rows = await prisma.clientProjectAccess.findMany({
30100
+ where: { userId },
30101
+ select: { projectId: true },
30102
+ orderBy: { projectId: "asc" }
30103
+ });
30104
+ return rows.map((r) => r.projectId);
30105
+ }
30106
+ async function hasProjectAccess(userId, projectId) {
30107
+ return await prisma.clientProjectAccess.count({ where: { userId, projectId } }) > 0;
30108
+ }
30109
+
30110
+ // src/routes/portal.ts
30111
+ var NOT_FOUND = { error: "not found" };
30112
+ var zPortalTicket = external_exports.object({
30113
+ category: zTicketCategory,
30114
+ title: external_exports.string().min(1).max(200),
30115
+ detail: external_exports.string().min(1).max(1e4)
30116
+ });
30117
+ async function specStages(ids) {
30118
+ const wanted = [...new Set(ids.filter((v) => !!v))];
30119
+ if (!wanted.length) return /* @__PURE__ */ new Map();
30120
+ const rows = await prisma.spec.findMany({ where: { id: { in: wanted } }, select: { id: true, stage: true } });
30121
+ return new Map(rows.map((r) => [r.id, r.stage]));
30122
+ }
30123
+ async function portal_default(app2) {
30124
+ app2.get("/portal/projects", async (req) => {
30125
+ const ids = await clientProjectIds(req.user.id);
30126
+ const rows = await prisma.project.findMany({ where: { id: { in: ids } }, orderBy: { name: "asc" } });
30127
+ return { items: rows.map(toPortalProject) };
30128
+ });
30129
+ app2.get("/portal/projects/:id/backlog", async (req, reply) => {
30130
+ const { id } = req.params;
30131
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30132
+ const { page, limit } = req.query;
30133
+ const specs = await liveSpecs({ project: id });
30134
+ return paginate(specs.map(toPortalSpec), page, limit);
30135
+ });
30136
+ app2.get("/portal/projects/:id/backlog/:specId", async (req, reply) => {
30137
+ const { id, specId } = req.params;
30138
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30139
+ const spec = (await liveSpecs({ project: id })).find((s2) => s2.id === specId);
30140
+ if (!spec) return reply.code(404).send(NOT_FOUND);
30141
+ return toPortalSpec(spec);
30142
+ });
30143
+ app2.get("/portal/projects/:id/tickets", async (req, reply) => {
30144
+ const { id } = req.params;
30145
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30146
+ const { page, limit } = req.query;
30147
+ const rows = await prisma.ticket.findMany({ where: { projectId: id }, orderBy: { createdAt: "desc" } });
30148
+ const stages = await specStages(rows.map((t) => t.specId));
30149
+ return paginate(rows.map((t) => toPortalTicket(t, stages.get(t.specId ?? "") ?? null)), page, limit);
30150
+ });
30151
+ app2.get("/portal/projects/:id/tickets/:ticketId", async (req, reply) => {
30152
+ const { id, ticketId } = req.params;
30153
+ if (!await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30154
+ const t = await prisma.ticket.findUnique({ where: { id: ticketId } });
30155
+ if (!t || t.projectId !== id) return reply.code(404).send(NOT_FOUND);
30156
+ const stages = await specStages([t.specId]);
30157
+ return toPortalTicketDetail(t, stages.get(t.specId ?? "") ?? null);
30158
+ });
30159
+ app2.post("/portal/projects/:id/tickets", async (req, reply) => {
30160
+ const { id } = req.params;
30161
+ const p = await prisma.project.findUnique({ where: { id } });
30162
+ if (!p || !await hasProjectAccess(req.user.id, id)) return reply.code(404).send(NOT_FOUND);
30163
+ if (!portalTicketRateOk(req.user.id, id)) return reply.code(429).send({ error: "terlalu banyak permintaan" });
30164
+ if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
30165
+ const upload = await parseTicketUpload(req);
30166
+ if (!upload) return reply.code(400).send({ error: "unggahan tak valid" });
30167
+ const parsed = zPortalTicket.safeParse({
30168
+ category: upload.fields.category,
30169
+ title: upload.fields.title,
30170
+ detail: upload.fields.detail
30171
+ });
30172
+ if (!parsed.success) return reply.code(400).send({ error: "field wajib tak lengkap / tak valid" });
30173
+ const { ticket } = await intakeTicket({
30174
+ projectId: id,
30175
+ projectName: p.name,
30176
+ category: parsed.data.category,
30177
+ title: parsed.data.title,
30178
+ detail: parsed.data.detail,
30179
+ reporterEmail: req.user.email,
30180
+ files: upload.files
30181
+ });
30182
+ return reply.code(201).send(toPortalTicket(ticket, null));
30183
+ });
30184
+ }
30185
+
30186
+ // src/routes/client-accounts.ts
29993
30187
  init_src();
29994
30188
  init_db();
29995
30189
 
@@ -30029,7 +30223,13 @@ async function lookupSession(token) {
30029
30223
  });
30030
30224
  return null;
30031
30225
  }
30032
- return { id: s2.user.id, email: s2.user.email, createdAt: s2.user.createdAt.toISOString() };
30226
+ if (s2.user.disabled) return null;
30227
+ return {
30228
+ id: s2.user.id,
30229
+ email: s2.user.email,
30230
+ role: s2.user.role,
30231
+ createdAt: s2.user.createdAt.toISOString()
30232
+ };
30033
30233
  }
30034
30234
  async function deleteSession(token) {
30035
30235
  await prisma.session.deleteMany({ where: { id: sessionId(token) } });
@@ -30066,8 +30266,83 @@ function cookieOpts() {
30066
30266
  };
30067
30267
  }
30068
30268
 
30269
+ // src/routes/client-accounts.ts
30270
+ var view9 = (u) => ({
30271
+ id: u.id,
30272
+ email: u.email,
30273
+ disabled: u.disabled,
30274
+ createdAt: u.createdAt.toISOString(),
30275
+ projects: u.projectAccess.map((a) => a.projectId).sort()
30276
+ });
30277
+ var load = (id) => prisma.user.findFirst({
30278
+ where: { id, role: "client" },
30279
+ include: { projectAccess: { select: { projectId: true } } }
30280
+ });
30281
+ async function setAccess(userId, projects) {
30282
+ const ids = [...new Set(projects)];
30283
+ const known = await prisma.project.findMany({ where: { id: { in: ids } }, select: { id: true } });
30284
+ if (known.length !== ids.length) return null;
30285
+ await prisma.clientProjectAccess.deleteMany({ where: { userId, projectId: { notIn: ids } } });
30286
+ for (const projectId of ids)
30287
+ await prisma.clientProjectAccess.upsert({
30288
+ where: { userId_projectId: { userId, projectId } },
30289
+ update: {},
30290
+ create: { userId, projectId }
30291
+ });
30292
+ return ids;
30293
+ }
30294
+ async function client_accounts_default(app2) {
30295
+ app2.get("/client-accounts", async () => {
30296
+ const rows = await prisma.user.findMany({
30297
+ where: { role: "client" },
30298
+ orderBy: { createdAt: "asc" },
30299
+ include: { projectAccess: { select: { projectId: true } } }
30300
+ });
30301
+ return { items: rows.map(view9) };
30302
+ });
30303
+ app2.post("/client-accounts", async (req, reply) => {
30304
+ const p = zCreateClientAccount.safeParse(req.body);
30305
+ if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30306
+ if (await prisma.user.findUnique({ where: { email: p.data.email } }))
30307
+ return reply.code(409).send({ error: "email dipakai" });
30308
+ const user = await prisma.user.create({
30309
+ data: { email: p.data.email, passwordHash: await hashPassword(p.data.password), role: "client" }
30310
+ });
30311
+ if (await setAccess(user.id, p.data.projects) === null) {
30312
+ await prisma.user.delete({ where: { id: user.id } });
30313
+ return reply.code(400).send({ error: "ada project yang tidak dikenal" });
30314
+ }
30315
+ return reply.code(201).send(view9(await load(user.id)));
30316
+ });
30317
+ app2.patch("/client-accounts/:id", async (req, reply) => {
30318
+ const { id } = req.params;
30319
+ const p = zUpdateClientAccount.safeParse(req.body);
30320
+ if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30321
+ if (!await load(id)) return reply.code(404).send({ error: "not found" });
30322
+ if (p.data.projects && await setAccess(id, p.data.projects) === null)
30323
+ return reply.code(400).send({ error: "ada project yang tidak dikenal" });
30324
+ if (p.data.disabled !== void 0)
30325
+ await prisma.user.update({ where: { id }, data: { disabled: p.data.disabled } });
30326
+ if (p.data.password)
30327
+ await prisma.user.update({ where: { id }, data: { passwordHash: await hashPassword(p.data.password) } });
30328
+ if (p.data.disabled || p.data.password) await deleteUserSessions(id);
30329
+ return view9(await load(id));
30330
+ });
30331
+ app2.delete("/client-accounts/:id", async (req, reply) => {
30332
+ const { id } = req.params;
30333
+ if (!await load(id)) return reply.code(404).send({ error: "not found" });
30334
+ await prisma.user.delete({ where: { id } });
30335
+ return reply.code(204).send();
30336
+ });
30337
+ }
30338
+
30339
+ // src/app.ts
30340
+ var import_multipart = __toESM(require_multipart2(), 1);
30341
+
30069
30342
  // src/routes/auth.ts
30070
- var view9 = (u) => ({ id: u.id, email: u.email, createdAt: u.createdAt.toISOString() });
30343
+ init_src();
30344
+ init_db();
30345
+ var view10 = (u) => ({ id: u.id, email: u.email, role: u.role, createdAt: u.createdAt.toISOString() });
30071
30346
  async function issue(reply, userId) {
30072
30347
  const token = await createSession2(userId);
30073
30348
  reply.setCookie(COOKIE_NAME, token, cookieOpts());
@@ -30085,20 +30360,20 @@ async function auth_default(app2) {
30085
30360
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
30086
30361
  });
30087
30362
  await issue(reply, user.id);
30088
- return { user: view9(user) };
30363
+ return { user: view10(user) };
30089
30364
  });
30090
30365
  app2.post("/auth/login", async (req, reply) => {
30091
30366
  if (loginThrottle(req.ip).blocked) return reply.code(429).send({ error: "too many attempts" });
30092
30367
  const p = zLogin.safeParse(req.body);
30093
30368
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30094
30369
  const user = await prisma.user.findUnique({ where: { email: p.data.email } });
30095
- if (!user || !await verifyPassword(p.data.password, user.passwordHash)) {
30370
+ if (!user || user.disabled || !await verifyPassword(p.data.password, user.passwordHash)) {
30096
30371
  noteLoginFail(req.ip);
30097
30372
  return reply.code(401).send({ error: "email atau password salah" });
30098
30373
  }
30099
30374
  clearLoginFails(req.ip);
30100
30375
  await issue(reply, user.id);
30101
- return { user: view9(user) };
30376
+ return { user: view10(user) };
30102
30377
  });
30103
30378
  app2.post("/auth/logout", async (req, reply) => {
30104
30379
  const token = req.cookies?.[COOKIE_NAME];
@@ -30106,19 +30381,24 @@ async function auth_default(app2) {
30106
30381
  reply.clearCookie(COOKIE_NAME, { path: "/" });
30107
30382
  return reply.code(204).send();
30108
30383
  });
30109
- app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view9));
30384
+ app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view10));
30110
30385
  app2.post("/auth/users", async (req, reply) => {
30111
30386
  const p = zSignup.safeParse(req.body);
30112
30387
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30113
30388
  if (await prisma.user.findUnique({ where: { email: p.data.email } }))
30114
30389
  return reply.code(409).send({ error: "email dipakai" });
30115
30390
  const user = await prisma.user.create({
30116
- data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
30391
+ // SPEC-617 · pintu ini melahirkan OPERATOR. Akun klien punya pintunya sendiri
30392
+ // (/api/client-accounts) supaya "undang rekan" dan "beri akses klien" tak pernah tertukar.
30393
+ data: { email: p.data.email, passwordHash: await hashPassword(p.data.password), role: "admin" }
30117
30394
  });
30118
- return view9(user);
30395
+ return view10(user);
30119
30396
  });
30120
30397
  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" });
30398
+ const target2 = await prisma.user.findUnique({ where: { id: req.params.id } });
30399
+ if (!target2) return reply.code(204).send();
30400
+ if (target2.role === "admin" && await prisma.user.count({ where: { role: "admin" } }) <= 1)
30401
+ return reply.code(400).send({ error: "tak bisa hapus admin terakhir" });
30122
30402
  await prisma.user.deleteMany({ where: { id: req.params.id } });
30123
30403
  return reply.code(204).send();
30124
30404
  });
@@ -30135,7 +30415,7 @@ async function auth_default(app2) {
30135
30415
  });
30136
30416
  await deleteUserSessions(user.id);
30137
30417
  await issue(reply, user.id);
30138
- return { user: view9(user) };
30418
+ return { user: view10(user) };
30139
30419
  });
30140
30420
  }
30141
30421
 
@@ -30148,15 +30428,15 @@ async function agent_tokens_default(app2) {
30148
30428
  app2.post("/agent-tokens", async (req, reply) => {
30149
30429
  const p = zAgentTokenCreate.safeParse(req.body);
30150
30430
  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 });
30431
+ const { view: view11, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
30432
+ return reply.code(201).send({ ...view11, token });
30153
30433
  });
30154
30434
  app2.patch("/agent-tokens/:id", async (req, reply) => {
30155
30435
  const p = zAgentTokenPatch.safeParse(req.body);
30156
30436
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
30157
30437
  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" });
30438
+ const view11 = await patchAgentToken(id, p.data);
30439
+ return view11 ? reply.send(view11) : reply.code(404).send({ error: "not found" });
30160
30440
  });
30161
30441
  app2.delete("/agent-tokens/:id", async (req, reply) => {
30162
30442
  const { id } = req.params;
@@ -30201,7 +30481,7 @@ function capabilityForRoute(method, path) {
30201
30481
  const rw = (d) => `${d}:${read ? "read" : "write"}`;
30202
30482
  const seg = path.replace(/^\/api\/?/, "").replace(/\/+$/, "").split("/").filter(Boolean);
30203
30483
  const top = seg[0] ?? "";
30204
- if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync") return "COOKIE_ONLY";
30484
+ if (top === "auth" || top === "agent-tokens" || top === "device-tokens" || top === "sync" || top === "portal" || top === "client-accounts") return "COOKIE_ONLY";
30205
30485
  if (top === "limits" || top === "update" || top === "events" || top === "fs" || top === "health")
30206
30486
  return read ? "GLOBAL_READ" : "COOKIE_ONLY";
30207
30487
  if (top === "scheduler") return rw("settings");
@@ -30400,6 +30680,8 @@ function buildApp({ requireAuth = true, agentDocFile } = {}) {
30400
30680
  if (user) req.user = user;
30401
30681
  const path = req.url.split("?")[0] ?? req.url;
30402
30682
  if (PUBLIC.has(`${req.method} ${path}`)) return;
30683
+ if (user?.role === "client" && !clientRouteAllowed(req.method, path))
30684
+ return reply.code(403).send({ error: "portal klien: baca-saja" });
30403
30685
  if (path.startsWith("/api/sync") && path !== "/api/sync/now" && !path.startsWith("/api/sync/conflicts")) return;
30404
30686
  if (path.startsWith("/api/help")) return;
30405
30687
  if (user) return;
@@ -30455,6 +30737,8 @@ function buildApp({ requireAuth = true, agentDocFile } = {}) {
30455
30737
  await api.register(telegramRoutes);
30456
30738
  await api.register(webhooks_default);
30457
30739
  await api.register(changelog_default);
30740
+ await api.register(portal_default);
30741
+ await api.register(client_accounts_default);
30458
30742
  }, { prefix: "/api" });
30459
30743
  if (process.env.NODE_ENV === "production") {
30460
30744
  const dist = pickWebDir(dirname13(fileURLToPath4(import.meta.url)), process.env, existsSync13);