DEV Community

loganpierce2073
loganpierce2073

Posted on

Property Catalog Endpoint: Text Prompt Validation before Signed URL or Base64 Delivery

Short answer: treat text-to-image generation as an auditable job that happens to begin with an HTTP request: validate a versioned property-catalog schema, reserve an idempotency key before generation, persist the resulting artifact and its digest, then return Base64 only for deliberately small synchronous responses and a short-lived signed URL for normal delivery.

The image is not the hardest output. The hard output is a defensible record of why a particular image belongs to a particular catalog item. Messy property descriptions make that distinction important: a phrase such as bright unit near transit is useful creative input, but it is not structured evidence for the number of bedrooms, the presence of a balcony, or any other attribute that the source text did not establish. Generation must not turn ambiguity into catalog fact.

Reject early.

How should a Node.js Express text-to-image endpoint validate prompts and return images?

A Node.js Express service should expose a narrow JSON contract even if generation sits behind a gateway or a separate worker. The framework is an adapter; correctness belongs in a framework-independent request validator and job service. Require a catalog item identifier, a nonempty source description, an explicit style chosen from an allowlist, a supported delivery mode, and an Idempotency-Key header. Reject unknown fields so that misspellings cannot silently alter intent.

Do not accept one unconstrained prompt string as the system of record. Preserve the original description, derive a normalized prompt document, and assign that document a schema version. In a property catalog, a useful internal document separates source-backed details from presentation instructions:

type PromptDocument struct {
    SchemaVersion     string   `json:"schema_version"`
    CatalogItemID     string   `json:"catalog_item_id"`
    SourceDescription string   `json:"source_description"`
    AllowedFacts      []string `json:"allowed_facts"`
    Style             string   `json:"style"`
    NegativeRules     []string `json:"negative_rules"`
}
Enter fullscreen mode Exit fullscreen mode

AllowedFacts is not a license for an extraction model to guess. It is the output of a separate validation step whose evidence remains tied to the source description. If the text says only two rooms, the pipeline should not silently normalize that to two bedrooms; it should preserve the ambiguous wording or send the item to review. The same restraint applies to people, logos, addresses, and condition claims. Your exact review rules will vary with jurisdiction and catalog policy.

The HTTP response should describe an artifact rather than pretend the image bytes are the whole transaction. A stable response can carry request_id, catalog_item_id, prompt_schema_version, artifact_sha256, media_type, and exactly one delivery field: signed_url or base64. Express can serialize that contract directly, while the generation client, object store, and audit repository stay behind interfaces.

Structured correctness comes before visual quality

A visually pleasing result can still be operationally wrong. For catalog enrichment, acceptance should have two gates: first verify the response envelope and its relationship to the request; then apply whatever human or automated visual review the business has approved. Never let the second gate conceal a failure in the first.

The envelope checks are deterministic. The catalog item ID must match. The recorded prompt-schema version must be the version used to render. The media type must be on an allowlist. The artifact digest must match the stored bytes. Exactly one delivery representation must be present. A result that fails any of those assertions is not publishable, even if someone likes the picture.

Consider a catalog row whose entire description is sunny two-room flat, recently refreshed, close to the station. The intake validator can retain that sentence and identify sunny, two-room, recently refreshed, and close to the station as source phrases, but the rendering document should keep them as qualified text rather than convert them into two bedrooms, a measured walking distance, a named transit line, or a particular renovation date. The canonical document, including its schema version and ordered fields, is hashed before work begins. If the client times out and retries, the same idempotency key and hash identify the existing job; if an operator edits two-room to two-bedroom and reuses the key, the different hash produces a conflict instead of quietly changing the meaning of an earlier request. After generation, the service hashes the exact PNG or JPEG bytes, stores that digest beside the object key, and presents the candidate for catalog review. The reviewer is deciding whether the artifact is acceptable for the listing under the organization's rules, not certifying facts that were absent from the source. If approval follows, the catalog association records both the artifact digest and the prompt-schema version. This chain is deliberately more elaborate than concatenating strings into a prompt, because each boundary answers a different reconciliation question: what the source asserted, what the renderer received, which bytes were produced, and who authorized those bytes for publication.

This is an exactly-once mindset, not a claim that a networked generator executes exactly once. Reserve the idempotency key and a hash of the canonical request in durable storage before calling the generator. When the same key and same request return, serve the recorded result. When the same key arrives with a different request hash, return 409 Conflict. If a client loses the response after generation, a retry then reconciles against the reservation instead of creating an unrelated second artifact.

The audit record should be append-oriented and boring: request ID, actor or service principal, idempotency key, canonical request hash, prompt-schema version, generator configuration identifier, timestamps, artifact digest, storage key, review disposition, and the reason for any rejection. Avoid logging signed URLs or Base64 bodies; logs need identifiers and digests, while access to image content belongs behind the artifact authorization boundary.

I’m not sure which retention period or image-use restriction applies to a given deployment without its jurisdiction, contracts, and internal policy. That uncertainty must be resolved by legal and compliance owners before launch, then encoded as retention, deletion, access, and review controls rather than left in a README. An audit trail demonstrates what the service did; it does not determine what the service was allowed to do.

Signed URL or Base64: which image delivery mode should the API return?

Prefer a signed URL for the ordinary catalog path. It keeps large binary data out of JSON, allows the application to authorize access for a limited interval, and lets image retrieval scale independently from generation. Store the object under an opaque key, bind authorization to the intended operation, keep expiry short enough for the workflow, and return the content digest separately so consumers can verify what they received.

Base64 remains useful when a caller truly needs a self-contained response and the service enforces a small byte ceiling. The catch is that Base64 enlarges the representation, occupies application memory during encoding and decoding, and encourages intermediaries to retain the full image inside request traces. It is not suitable as the default for high-resolution catalog assets or bulk enrichment. Use it for controlled previews, tests, or another bounded case with explicit response-size and timeout limits.

Keep both.

Make the mode explicit in the request rather than switching according to an undocumented size threshold. That gives clients predictable behavior and gives operators separate latency, payload-size, and failure metrics for each path. A 413 Payload Too Large response should tell the caller to request URL delivery; it should not silently change the response shape.

Concern Signed URL Base64 in JSON
Normal catalog delivery Preferred Use only with a strict byte ceiling
Authorization Short-lived artifact access Inherited from the API response
Retries Re-fetch the recorded artifact Re-serialize the full payload
Logging risk Redact the URL query Exclude the image field entirely
Integrity Verify the returned digest Verify decoded bytes against the digest

A small auditable handler boundary

The following Go reference implementation shows the service boundary that an Express route can mirror. It is intentionally built around injected interfaces: production code must supply durable idempotency, generation, artifact storage, URL signing, authentication, and append-only audit implementations. The handler does not infer facts from the description, and it never lets a delivery preference modify generation identity.

package images

import (
    "context"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "strings"
    "time"
)

const maxRequestBytes = 64 << 10

type GenerateRequest struct {
    CatalogItemID     string `json:"catalog_item_id"`
    SourceDescription string `json:"source_description"`
    Style             string `json:"style"`
    Delivery          string `json:"delivery"`
}

type Result struct {
    RequestID          string `json:"request_id"`
    CatalogItemID      string `json:"catalog_item_id"`
    PromptSchemaVersion string `json:"prompt_schema_version"`
    ArtifactSHA256     string `json:"artifact_sha256"`
    MediaType          string `json:"media_type"`
    ObjectKey          string `json:"-"`
    SignedURL          string `json:"signed_url,omitempty"`
    Base64             string `json:"base64,omitempty"`
}

type Reservation struct {
    RequestHash string
    Result      *Result
}

type IdempotencyStore interface {
    Reserve(context.Context, string, string) (Reservation, bool, error)
    Complete(context.Context, string, Result) error
}

type Generator interface {
    Generate(context.Context, PromptDocument) ([]byte, string, error)
}

type Artifacts interface {
    Put(context.Context, []byte, string) (string, error)
    Get(context.Context, string) ([]byte, error)
    SignGet(context.Context, string, time.Duration) (string, error)
}

type Auditor interface {
    Append(context.Context, string, map[string]string) error
}

type Handler struct {
    Keys      IdempotencyStore
    Generator Generator
    Artifacts Artifacts
    Audit     Auditor
}

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }

    key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
    if key == "" {
        http.Error(w, "Idempotency-Key is required", http.StatusBadRequest)
        return
    }

    var in GenerateRequest
    dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBytes))
    dec.DisallowUnknownFields()
    if err := dec.Decode(&in); err != nil || !valid(in) {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    canonical, _ := json.Marshal(in)
    sum := sha256.Sum256(canonical)
    requestHash := hex.EncodeToString(sum[:])
    reservation, created, err := h.Keys.Reserve(r.Context(), key, requestHash)
    if err != nil {
        http.Error(w, "request could not be recorded", http.StatusServiceUnavailable)
        return
    }
    if !created {
        if reservation.RequestHash != requestHash {
            http.Error(w, "idempotency key reused with different input", http.StatusConflict)
            return
        }
        if reservation.Result == nil {
            http.Error(w, "request is still processing", http.StatusConflict)
            return
        }
        h.respond(r.Context(), w, in.Delivery, *reservation.Result)
        return
    }

    doc := PromptDocument{
        SchemaVersion:     "property-catalog/v1",
        CatalogItemID:     in.CatalogItemID,
        SourceDescription: in.SourceDescription,
        Style:             in.Style,
        NegativeRules:     []string{"do not add unsupported property features"},
    }
    data, mediaType, err := h.Generator.Generate(r.Context(), doc)
    if err != nil {
        http.Error(w, "generation was not accepted", http.StatusUnprocessableEntity)
        return
    }
    if mediaType != "image/png" && mediaType != "image/jpeg" {
        http.Error(w, "unsupported generated media type", http.StatusUnprocessableEntity)
        return
    }

    artifactSum := sha256.Sum256(data)
    result := Result{
        RequestID:           key,
        CatalogItemID:       in.CatalogItemID,
        PromptSchemaVersion: doc.SchemaVersion,
        ArtifactSHA256:      hex.EncodeToString(artifactSum[:]),
        MediaType:           mediaType,
    }
    result.ObjectKey, err = h.Artifacts.Put(r.Context(), data, mediaType)
    if err != nil || h.Keys.Complete(r.Context(), key, result) != nil {
        http.Error(w, "artifact could not be committed", http.StatusServiceUnavailable)
        return
    }
    _ = h.Audit.Append(r.Context(), "image_generation_committed", map[string]string{
        "request_id": key, "request_hash": requestHash, "artifact_sha256": result.ArtifactSHA256,
    })
    h.respond(r.Context(), w, in.Delivery, result)
}

func valid(in GenerateRequest) bool {
    styleOK := in.Style == "neutral-interior" || in.Style == "clean-exterior"
    deliveryOK := in.Delivery == "signed_url" || in.Delivery == "base64"
    return in.CatalogItemID != "" && strings.TrimSpace(in.SourceDescription) != "" && styleOK && deliveryOK
}

func (h Handler) respond(ctx context.Context, w http.ResponseWriter, delivery string, result Result) {
    var err error
    if delivery == "signed_url" {
        result.SignedURL, err = h.Artifacts.SignGet(ctx, result.ObjectKey, 10*time.Minute)
    } else {
        var data []byte
        data, err = h.Artifacts.Get(ctx, result.ObjectKey)
        if err == nil {
            result.Base64 = base64.StdEncoding.EncodeToString(data)
        }
    }
    if err != nil {
        http.Error(w, "artifact delivery unavailable", http.StatusServiceUnavailable)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    _ = json.NewEncoder(w).Encode(result)
}

Enter fullscreen mode Exit fullscreen mode

The example leaves one decision visible rather than hiding it: an in-progress duplicate returns a conflict response, so the client can retry with the same key. A production contract may instead return 202 Accepted plus a status resource. Choose one semantic, document it, and test response-loss recovery; don't allow two workers to generate merely because two HTTP requests arrived. Also set a Base64 artifact-size limit before loading bytes, even though that storage-specific check is outside this handler.

Failure semantics, tests, and observability

Separate client-invalid input, policy rejection, duplicate-key conflict, generation rejection, and artifact-delivery failure. Their retry rules differ. A malformed prompt should not be retried unchanged. A request already in progress may be polled or retried under the same idempotency key. If URL signing fails after the artifact was committed, the generation record remains complete and a later request can produce fresh delivery authorization for the same object. This separation prevents delivery trouble from being mislabeled as generation failure.

Test the invariants around boundaries, not just the happy response. Property-based tests can produce empty descriptions, unknown JSON members, unsupported styles, and alternate delivery modes. Concurrency tests should submit the same key simultaneously and assert one reservation. Contract tests should decode every success response and enforce the one-of rule for signed_url and base64. Artifact tests should recompute SHA-256 after retrieval. A reconciliation test should begin with a committed object and an interrupted response, retry the original request, and assert the same artifact digest.

Operational metrics need the same discipline: validation rejections by reason, idempotency replays, key conflicts, generation latency, commit latency, signing latency, output bytes, Base64 response bytes, review disposition, and orphaned reservation age. Keep dimensions bounded; request IDs belong in traces and audit records, not metric labels. Alert on violated invariants and stalled state transitions, then use reconciliation to compare completed generation records with stored artifacts.

This synchronous request design is not suitable when a catalog import contains many items or generation can outlive the application's request deadline. In that case, stick with an asynchronous batch or queue-backed worker contract: submission, status, result association, and reconciliation become explicit stages, while the foreground endpoint returns acceptance rather than waiting for image bytes. A self-hosted gateway can normalize access to multiple model backends, but neither choice removes the need for the application-owned prompt schema, idempotency ledger, artifact digest, or review decision. Those are business records, not transport features.

Roll out without losing the ledger

Start in shadow mode: validate and construct prompt documents without publishing images. Review ambiguous descriptions and adjust the schema, allowlists, and rejection reasons. Next, enable generation for a small catalog segment, store every artifact under a nonpublic key, and require human approval before association with a listing. Only after reconciliation shows that request records, objects, digests, and review states agree should automatic publication expand.

Do not migrate by replacing old images in place. Write a new versioned association from the catalog item to the approved artifact, retain the prior association according to policy, and make rollback a metadata operation. The compact decision rule survives every phase: source-ground the prompt, reserve once, generate behind an interface, commit with a digest, and deliver through an explicit bounded mode.

References

Top comments (0)