Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | // Pro Import Routes (GST-4 dev scaffolding).
//
// M1 ships exactly one dev-only endpoint: `POST /:proId/imports/dev-enqueue`.
// It writes a `pro_imports` row and enqueues an `IMPORT_QUEUE` message so
// the local integration test in the DoD can produce the required
// `pro_import_events(kind='progress')` heartbeat row.
//
// The real user-facing endpoint with consent UX, idempotency handling, and
// cost-cap policy is M3 ([plan ยง3](/GST/issues/GST-2#document-plan)). Do not
// route this one to the Portal.
import { Hono } from "hono";
import type { Dal } from "../../dal";
import type { Services } from "../../services";
import type { getDb } from "../../db";
import * as schema from "../../db/schema";
import { success, handleError } from "../../lib/response";
import {
ForbiddenError,
NotFoundError,
ValidationError,
} from "../../lib/errors";
import { requireProAccess } from "../../middleware";
import type { ImportQueueMessage } from "../../import-agent";
type Env = {
Bindings: CloudflareBindings;
Variables: {
user: { id: string; name: string; email: string } | null;
session: unknown;
dal: Dal;
services: Services;
db: ReturnType<typeof getDb>;
proId: string;
proRole: string;
};
};
const importsRoutes = new Hono<Env>();
// Block non-dev environments at the router level. Production readiness for
// this endpoint is intentionally gated until the M3 policy layer ships.
function assertDevEnvironment(env: CloudflareBindings): void {
const name = env.ENVIRONMENT ?? "local";
if (name !== "local" && name !== "dev") {
throw new ForbiddenError(
"dev-enqueue is only available in local/dev environments",
);
}
}
// Simple normalization so re-submitting the same URL hits the idempotency key.
function normalizeUrl(raw: string): string {
try {
const url = new URL(raw);
url.hash = "";
const cleanedPath = url.pathname.endsWith("/") && url.pathname.length > 1
? url.pathname.slice(0, -1)
: url.pathname;
return `${url.origin.toLowerCase()}${cleanedPath}${url.search}`;
} catch {
throw new ValidationError("sourceUrl is not a valid URL");
}
}
async function hashIdempotencyKey(
proId: string,
normalizedUrl: string,
): Promise<string> {
const data = new TextEncoder().encode(`${proId}|${normalizedUrl}`);
const digest = await crypto.subtle.digest("SHA-256", data);
const bytes = new Uint8Array(digest);
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}
importsRoutes.post(
"/:proId/imports/dev-enqueue",
requireProAccess,
async (c) => {
try {
assertDevEnvironment(c.env);
const proId = c.get("proId");
const dal = c.get("dal");
const db = c.get("db");
const pro = await dal.pros.findById(proId);
if (!pro) {
throw new NotFoundError("Pro not found");
}
const body = await c.req.json<{
sourceUrl?: string;
consentAt?: number;
}>();
if (!body.sourceUrl) {
throw new ValidationError("sourceUrl is required");
}
const normalized = normalizeUrl(body.sourceUrl);
const idempotencyKey = await hashIdempotencyKey(proId, normalized);
if (!c.env.IMPORT_QUEUE) {
return c.json(
{
success: false,
error: {
code: "SERVER_MISCONFIGURED",
message: "IMPORT_QUEUE binding is not available",
},
},
500,
);
}
const importId = `imp_${crypto.randomUUID()}`;
const nowMs = Date.now();
await db.insert(schema.proImports).values({
id: importId,
proId,
sourceUrl: normalized,
sourceType: "website",
status: "queued",
consentAt: body.consentAt ?? nowMs,
idempotencyKey,
});
const queueMessage: ImportQueueMessage = {
importId,
proId,
sourceUrl: normalized,
timestamp: nowMs,
};
await c.env.IMPORT_QUEUE.send(queueMessage);
return success(c, { importId, status: "queued" });
} catch (err) {
return handleError(c, err);
}
},
);
export default importsRoutes;
|