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.
packages/api/src/routes/targets.tspackages/api/src/routes/releases.tspackages/api/src/routes/webhooks.tspackages/api/src/routes/platform-contracts.test.tsPassed 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.
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.
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));