hanoman 0.1.24 → 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.24",
3
- "sha": "3024ec0",
4
- "builtAt": "2026-08-10T03:28:41.335Z"
2
+ "version": "0.1.25",
3
+ "sha": "53d6464",
4
+ "builtAt": "2026-08-10T04:26:46.836Z"
5
5
  }
package/dist/server.js CHANGED
@@ -26094,10 +26094,64 @@ async function pruneOldTickets(now = Date.now()) {
26094
26094
  }
26095
26095
  }
26096
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
+
26097
26150
  // src/services/help-ratelimit.ts
26098
26151
  init_config2();
26099
26152
  var ipBuckets = /* @__PURE__ */ new Map();
26100
26153
  var projBuckets = /* @__PURE__ */ new Map();
26154
+ var acctBuckets = /* @__PURE__ */ new Map();
26101
26155
  function take(map, k, cap, now) {
26102
26156
  const b = map.get(k) ?? { tokens: cap, ts: now };
26103
26157
  b.tokens = Math.min(cap, b.tokens + (now - b.ts) * cap / 6e4);
@@ -26115,13 +26169,15 @@ function helpRateOk(projectId, ip, now = Date.now()) {
26115
26169
  const projCap = effectiveInt("HANOMAN_HELP_RATE_PER_MIN_PROJECT") ?? 20;
26116
26170
  return take(ipBuckets, ip, ipCap, now) && take(projBuckets, projectId, projCap, now);
26117
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
+ }
26118
26177
 
26119
26178
  // src/routes/help.ts
26120
26179
  var CATEGORIES = ["bug", "fitur", "pertanyaan", "lainnya"];
26121
26180
  var HONEYPOT_FIELD = "hc_trap";
26122
- var OK_MIME = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
26123
- var MAX_FILES2 = 3;
26124
- var MAX_BYTES = 5 * 1024 * 1024;
26125
26181
  var zField = external_exports.object({
26126
26182
  category: zTicketCategory,
26127
26183
  title: external_exports.string().min(1).max(200),
@@ -26141,21 +26197,9 @@ async function help_default(app2) {
26141
26197
  if (!p || !p.helpEnabled) return reply.code(404).send({ error: "not found" });
26142
26198
  if (!helpRateOk(slug, req.ip)) return reply.code(429).send({ error: "terlalu banyak permintaan" });
26143
26199
  if (!req.isMultipart?.()) return reply.code(400).send({ error: "butuh multipart/form-data" });
26144
- const fields = {};
26145
- const files = [];
26146
- try {
26147
- for await (const part of req.parts()) {
26148
- if (part.type === "file") {
26149
- const buf = await part.toBuffer();
26150
- if (part.file?.truncated || !OK_MIME.has(part.mimetype) || files.length >= MAX_FILES2) continue;
26151
- files.push({ buf, mime: part.mimetype, name: String(part.filename ?? "gambar") });
26152
- } else {
26153
- fields[part.fieldname] = String(part.value ?? "");
26154
- }
26155
- }
26156
- } catch {
26157
- return reply.code(400).send({ error: "unggahan tak valid" });
26158
- }
26200
+ const upload = await parseTicketUpload(req);
26201
+ if (!upload) return reply.code(400).send({ error: "unggahan tak valid" });
26202
+ const { fields, files } = upload;
26159
26203
  if (fields[HONEYPOT_FIELD]) {
26160
26204
  console.warn(`help: honeypot terpicu \xB7 project=${slug} ip=${req.ip}`);
26161
26205
  return reply.code(200).send({ ok: true });
@@ -26167,23 +26211,15 @@ async function help_default(app2) {
26167
26211
  email: fields.email
26168
26212
  });
26169
26213
  if (!parsed.success) return reply.code(400).send({ error: "field wajib tak lengkap / tak valid" });
26170
- const { ticket, key } = await createTicket({
26214
+ const { ticket, key } = await intakeTicket({
26171
26215
  projectId: slug,
26216
+ projectName: p.name,
26172
26217
  category: parsed.data.category,
26173
26218
  title: parsed.data.title,
26174
26219
  detail: parsed.data.detail,
26175
- reporterEmail: parsed.data.email
26220
+ reporterEmail: parsed.data.email,
26221
+ files
26176
26222
  });
26177
- await notifySynced("ticket", ticket.id);
26178
- for (const f of files) {
26179
- const { storageKey, size } = await saveUpload(f.buf, f.mime);
26180
- const att = await prisma.ticketAttachment.create({
26181
- data: { ticketId: ticket.id, projectId: slug, filename: f.name.slice(0, 200), mimeType: f.mime, size, storageKey }
26182
- });
26183
- await notifySynced("ticketAttachment", att.id);
26184
- }
26185
- await recordNewTicket(ticket.id, slug, p.name, parsed.data.category, parsed.data.title);
26186
- void pruneOldTickets();
26187
26223
  const statusPath = `/help/${encodeURIComponent(slug)}/status/${encodeURIComponent(key)}`;
26188
26224
  return reply.code(201).send({ number: ticket.number, key, statusPath });
26189
26225
  });
@@ -30038,18 +30074,20 @@ async function webhooks_default(app2) {
30038
30074
  }
30039
30075
 
30040
30076
  // src/routes/portal.ts
30077
+ init_zod();
30041
30078
  init_src();
30042
30079
  init_db();
30043
30080
 
30044
30081
  // src/services/client-access.ts
30045
30082
  init_db();
30046
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";
30047
30085
  function clientRouteAllowed(method, path) {
30048
30086
  const seg = segments(path);
30049
30087
  if (seg.includes("..")) return false;
30050
30088
  const top = seg[0] ?? "";
30051
30089
  const read = method === "GET" || method === "HEAD";
30052
- if (top === "portal") return read;
30090
+ if (top === "portal") return read || isPortalTicketSubmit(method, seg);
30053
30091
  if (top === "help") return true;
30054
30092
  if (top === "auth") {
30055
30093
  const sub = seg[1] ?? "";
@@ -30071,6 +30109,11 @@ async function hasProjectAccess(userId, projectId) {
30071
30109
 
30072
30110
  // src/routes/portal.ts
30073
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
+ });
30074
30117
  async function specStages(ids) {
30075
30118
  const wanted = [...new Set(ids.filter((v) => !!v))];
30076
30119
  if (!wanted.length) return /* @__PURE__ */ new Map();
@@ -30113,6 +30156,31 @@ async function portal_default(app2) {
30113
30156
  const stages = await specStages([t.specId]);
30114
30157
  return toPortalTicketDetail(t, stages.get(t.specId ?? "") ?? null);
30115
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
+ });
30116
30184
  }
30117
30185
 
30118
30186
  // src/routes/client-accounts.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {