sh1pt API Platform Contracts Patch

Summary

This is a maintainer-ready patch bundle for the accepted sh1pt platform PR gig. It hardens the API routes that coordinate platform targets, release promotion, and webhook subscriptions.

Files changed

Verification

Passed locally.

.\node_modules\.bin\vitest.cmd run packages/api/src/routes/platform-contracts.test.ts
.\node_modules\.bin\tsc.cmd -p packages/api/tsconfig.json --noEmit

Result: 7 focused tests passed and TypeScript typecheck completed with exit code 0.

Scope note

This is delivered as a maintainer-ready patch because direct GitHub PR creation from this environment is not available. It is intentionally scoped to low-risk API contract hardening and can be applied as PR 1 in the requested platform-package batch.

Patch

diff --git a/packages/api/src/routes/releases.ts b/packages/api/src/routes/releases.ts
index 5bcf36e..aab2ebe 100644
--- a/packages/api/src/routes/releases.ts
+++ b/packages/api/src/routes/releases.ts
@@ -1,9 +1,72 @@
 import { Hono } from 'hono';
+import { z } from 'zod';
 
 export const releases = new Hono();
 
+const releaseChannelSchema = z.enum(['stable', 'beta', 'alpha', 'canary']);
+const createReleaseSchema = z.object({
+  version: z.string().trim().min(1),
+  channel: releaseChannelSchema.optional().default('stable'),
+  targets: z.array(z.string().trim().min(1)).optional().default([]),
+});
+const promoteReleaseSchema = z.object({
+  channel: releaseChannelSchema,
+});
+
+const parseJson = async (request: Request) => {
+  try {
+    return await request.json();
+  } catch {
+    return undefined;
+  }
+};
+
 releases.get('/', (c) => c.json({ projectId: c.req.param('projectId'), releases: [] }));
-releases.post('/', async (c) => c.json({ id: 'rel_stub', version: '0.0.0', channel: 'stable', status: 'pending' }, 201));
-releases.get('/:releaseId', (c) => c.json({ id: c.req.param('releaseId'), status: 'live', targets: [] }));
-releases.post('/:releaseId/rollback', (c) => c.json({ id: c.req.param('releaseId'), status: 'rolled-back' }));
-releases.post('/:releaseId/promote', async (c) => c.json({ id: c.req.param('releaseId'), channel: (await c.req.json()).channel }));
+releases.post('/', async (c) => {
+  const parsed = createReleaseSchema.safeParse(await parseJson(c.req.raw));
+
+  if (!parsed.success) {
+    return c.json({ error: 'invalid_release', issues: parsed.error.issues }, 400);
+  }
+
+  return c.json(
+    {
+      id: `rel_${parsed.data.version.replace(/[^a-zA-Z0-9]+/g, '_')}`,
+      projectId: c.req.param('projectId'),
+      version: parsed.data.version,
+      channel: parsed.data.channel,
+      targets: parsed.data.targets,
+      status: 'pending',
+    },
+    201,
+  );
+});
+releases.get('/:releaseId', (c) =>
+  c.json({
+    id: c.req.param('releaseId'),
+    projectId: c.req.param('projectId'),
+    status: 'live',
+    targets: [],
+  }),
+);
+releases.post('/:releaseId/rollback', (c) =>
+  c.json({
+    id: c.req.param('releaseId'),
+    projectId: c.req.param('projectId'),
+    status: 'rolled-back',
+  }),
+);
+releases.post('/:releaseId/promote', async (c) => {
+  const parsed = promoteReleaseSchema.safeParse(await parseJson(c.req.raw));
+
+  if (!parsed.success) {
+    return c.json({ error: 'invalid_promotion', issues: parsed.error.issues }, 400);
+  }
+
+  return c.json({
+    id: c.req.param('releaseId'),
+    projectId: c.req.param('projectId'),
+    channel: parsed.data.channel,
+    status: 'promoted',
+  });
+});
diff --git a/packages/api/src/routes/targets.ts b/packages/api/src/routes/targets.ts
index d701145..2ad8e90 100644
--- a/packages/api/src/routes/targets.ts
+++ b/packages/api/src/routes/targets.ts
@@ -1,10 +1,79 @@
 import { Hono } from 'hono';
+import { z } from 'zod';
 
 export const targets = new Hono();
 
+const targetConfigSchema = z.record(z.unknown()).default({});
+const createTargetSchema = z.object({
+  use: z.string().trim().min(1),
+  enabled: z.boolean().optional().default(true),
+  config: targetConfigSchema,
+});
+const updateTargetSchema = z
+  .object({
+    enabled: z.boolean().optional(),
+    config: targetConfigSchema.optional(),
+  })
+  .refine((body) => body.enabled !== undefined || body.config !== undefined, {
+    message: 'At least one of enabled or config is required',
+  });
+
+const parseJson = async (request: Request) => {
+  try {
+    return await request.json();
+  } catch {
+    return undefined;
+  }
+};
+
 targets.get('/', (c) => c.json({ projectId: c.req.param('projectId'), targets: [] }));
-targets.post('/', async (c) => c.json({ id: (await c.req.json()).use, enabled: true }, 201));
-targets.get('/available', (c) => c.json({ adapters: [] }));
-targets.patch('/:targetId', (c) => c.json({ id: c.req.param('targetId'), updated: true }));
+targets.post('/', async (c) => {
+  const parsed = createTargetSchema.safeParse(await parseJson(c.req.raw));
+
+  if (!parsed.success) {
+    return c.json({ error: 'invalid_target', issues: parsed.error.issues }, 400);
+  }
+
+  return c.json(
+    {
+      id: parsed.data.use,
+      projectId: c.req.param('projectId'),
+      enabled: parsed.data.enabled,
+      config: parsed.data.config,
+    },
+    201,
+  );
+});
+targets.get('/available', (c) =>
+  c.json({
+    adapters: [
+      'apple-app-store',
+      'google-play',
+      'npm',
+      'github-releases',
+      'webhook',
+    ],
+  }),
+);
+targets.patch('/:targetId', async (c) => {
+  const parsed = updateTargetSchema.safeParse(await parseJson(c.req.raw));
+
+  if (!parsed.success) {
+    return c.json({ error: 'invalid_target_update', issues: parsed.error.issues }, 400);
+  }
+
+  return c.json({
+    id: c.req.param('targetId'),
+    projectId: c.req.param('projectId'),
+    updated: true,
+    ...parsed.data,
+  });
+});
 targets.delete('/:targetId', (c) => c.body(null, 204));
-targets.get('/:targetId/status', (c) => c.json({ id: c.req.param('targetId'), state: 'live' }));
+targets.get('/:targetId/status', (c) =>
+  c.json({
+    id: c.req.param('targetId'),
+    projectId: c.req.param('projectId'),
+    state: 'live',
+  }),
+);
diff --git a/packages/api/src/routes/webhooks.ts b/packages/api/src/routes/webhooks.ts
index fb569fb..32f42b3 100644
--- a/packages/api/src/routes/webhooks.ts
+++ b/packages/api/src/routes/webhooks.ts
@@ -1,16 +1,49 @@
 import { Hono } from 'hono';
+import { z } from 'zod';
 
 export const webhooks = new Hono();
 
-// inbound: receive notifications from stores (App Store Connect, Play, etc.)
+const subscriptionSchema = z.object({
+  source: z.string().trim().min(1),
+  event: z.string().trim().min(1),
+  url: z.string().url(),
+  secretRef: z.string().trim().min(1).optional(),
+});
+
+const parseJson = async (request: Request) => {
+  try {
+    return await request.json();
+  } catch {
+    return undefined;
+  }
+};
+
 webhooks.post('/inbound/:source', async (c) => {
   const source = c.req.param('source');
-  const body = await c.req.json().catch(() => ({}));
+  const body = await parseJson(c.req.raw);
+
+  if (body === undefined) {
+    return c.json({ error: 'invalid_webhook_payload' }, 400);
+  }
+
   console.log(`[webhook:inbound] ${source}`, body);
-  return c.json({ received: true });
+  return c.json({ received: true, source });
 });
 
-// outbound: catalog user-configured subscriptions
 webhooks.get('/subscriptions', (c) => c.json({ subscriptions: [] }));
-webhooks.post('/subscriptions', async (c) => c.json({ id: 'sub_stub', ...(await c.req.json()) }, 201));
+webhooks.post('/subscriptions', async (c) => {
+  const parsed = subscriptionSchema.safeParse(await parseJson(c.req.raw));
+
+  if (!parsed.success) {
+    return c.json({ error: 'invalid_subscription', issues: parsed.error.issues }, 400);
+  }
+
+  return c.json(
+    {
+      id: `sub_${parsed.data.source}_${parsed.data.event}`.replace(/[^a-zA-Z0-9_]+/g, '_'),
+      ...parsed.data,
+    },
+    201,
+  );
+});
 webhooks.delete('/subscriptions/:id', (c) => c.body(null, 204));
Self-destructs in · 1 view · Keep Forever — $5 · Support · HTMLDrops