Useful Windows Shortcuts

CategoryShortcutFunction
The EssentialsWin + DShow/Hide Desktop
Win + EOpen File Explorer
Win + LLock PC
Win + VClipboard History (See multiple copies)
Alt + TabSwitch between open apps
Window ControlWin + Arrow KeysSnap windows to sides/corners
Win + Shift + SScreenshot specific area
Win + TabOpen Task View (Virtual Desktops)
Win + Ctrl + DCreate a new Virtual Desktop
Win + NumberOpen app at that Taskbar position
System ToolsCtrl + Shift + EscOpen Task Manager directly
Win + IOpen Settings
Win + SSearch Windows
Win + XQuick Link Menu (Power User menu)
Win + . (Period)Emoji & GIF panel
Quick ActionsAlt + F4Close active window
Ctrl + Shift + TReopen last closed browser tab
Ctrl + ScrollZoom in or out
Win + PauseView System/Device specifications
Win + LInstant Lock (Safety first!)
Posted in Software, Computers, programming | Tagged , | Leave a comment

SvelteKit – SPA vs. SSG

This one has tripped me a few times so I thought I’d make a note about it. When reading or learning SvelteKit you might have come across two terms – SPA and SSG. They seem related but are not. SPA and SSG are disjoint concepts and are not mutually exclusive. It is possible to have all 4 combinations below:

SPASSGWhat it means
00
01
10
11

What SPA means – SPA is equivalent to putting:

export ssr = false

in src/routes/+layout.js. A Single Page Application as its name suggests exists as a single HTML file with references to JS chunks. The sub-pages (paths if you will) like /foo, /bar etc. are virtual in the sense that the JS application intercepts them and routing happens client-side – there is no call to the server when you request /foo, /bar etc. The SPA is bad for SEO but for internal applications in a company accessed by company-only employees, SEO does not matter. For completeness and accuracy we should add that API (XHR) calls still go to server (backend). SPA does not mean you cannot make any backend calls. The key point is that when you click on a link within the application, it handles it completely client-side. If you type the link in the browser address bar that’s different – the browser will download the application again (from its cache) and the app will start (boot) all over again.

Now let’s talk about SSG. SSG is equivalent to using adapter-static in svelte.config.js. The adapters influence what happens when you run npm run build. Adapters have no effect when you run npm run dev or npm serve. A static adapter can be used when there is no server-side logic in the SvelteKit app i.e., its a pure browser application. There is either no backend or (more likely) the backend is being developed and run as a separate app (Express.js for example). The static adapter will generate HTML+JS when you run npm run build. This HTML+JS has to be served somehow and that is outside the scope of static adapter. We assume you will host the HTML+JS on a NGINX server for example or any other way you wish.

Now as exercise challenge yourself and fill in the table above.

Now-a-days AI is really good and you can get all the answers using AI eliminating the need for blog posts. See below for what happens if I have server-side logic in a sveltekit app and try to use adapter-static. Does it still work?

No, it generally won’t work as expected if you have real server-side logic (things that need to run dynamically on the server at request time).

What adapter-static does

adapter-static turns your SvelteKit app into a pure static site (or SPA with a fallback). During npm run build, it:

  • Prerenders all pages into static HTML files.
  • Outputs only HTML, CSS, JS, and any prerendered JSON/endpoint files.
  • Deploys to any static host (Netlify, Vercel static, GitHub Pages, Cloudflare Pages, Surge, etc.).

There is no server runtime at all after the build.

What happens to server-side logic

Type of server-side code What happens with adapter-static Does it work?
+server.js / +server.ts endpoints Stripped out by default. Cannot run dynamically. No (unless export const prerender = true;)
+page.server.js / +layout.server.js Server load functions are stripped. Data won’t load at runtime. No (unless prerendered)
Universal load in +page.js Runs at build time during prerendering (if possible). Client-side version may run on navigation. Partially (static data only)
Dynamic server features (DB queries, auth, secrets, POST/PUT, etc.) Impossible — no server to execute them. No
Forms / actions Won’t work unless everything is prerendered or you move logic to an external backend. Usually no

By default, the build will fail with an error like:

@sveltejs/adapter-static: all routes must be fully prerenderable (unless using the 'fallback' option)

This is a safety check to prevent you from deploying a broken app where some routes are missing or non-functional.

When it can work (limited cases)

You can make some server-side code compatible if you prerender it at build time:

  • Add export const prerender = true; to the route (or to the root +layout.js to apply broadly).
  • For +server.js endpoints, this generates static files (e.g. /api/data.json becomes a static JSON file).
  • For server load functions, data is baked into the HTML at build time.

This works well for static sites (blogs, docs, marketing pages) or pages with data that rarely changes.

If the data must be dynamic (user-specific, real-time, depends on request time), it cannot work with adapter-static.

Recommended alternatives

  • Keep server-side logic → Use a different adapter:
    • @sveltejs/adapter-node (self-hosted Node server)
    • Platform-specific ones (Vercel, Netlify, Cloudflare, etc.) for serverless/edge functions.
  • Move the backend elsewhere → Keep adapter-static for the frontend and call an external API (REST, GraphQL, Supabase, your own Node/Express/FastAPI backend, etc.).
  • For SPA mode (client-side only routing) → You can use adapter-static with fallback: 'index.html' and export const ssr = false; in your root layout, but you still cannot have server-only logic.

Quick test / debugging tip

npm run preview often hides these issues because it still runs a dev-like server. Always test a real static build:

npm run build
npx serve ./build   # or http-server, python -m http.server, etc.

In short: adapter-static is great for fully static content, but it’s incompatible with dynamic server-side logic unless you prerender everything at build time or move that logic to a separate backend. If your app needs real server capabilities, switch to a non-static adapter.

Posted in Computers, programming, Software | Tagged , | Leave a comment

How BatchMeterUsage Actually Works (and the Gotchas)

If you’re building a SaaS product on AWS Marketplace with usage-based billing, you’ll be calling BatchMeterUsage. The API looks simple. The documentation is thin. And there are at least five things that will bite you in production if you don’t know about them upfront.

I’ve shipped 4 SaaS products on AWS Marketplace. Here’s what I learned about metering the hard way.

What BatchMeterUsage Does

BatchMeterUsage is the AWS Marketplace API that reports how much of your product a customer used. AWS uses these reports to bill the customer. You call it periodically (typically hourly), and each call contains a batch of usage records – one per customer per metering dimension.

A usage record looks like this:

{
    "CustomerIdentifier": "cust-abc-123",
    "Dimension": "api_calls",
    "Quantity": 1500,
    "Timestamp": "2025-03-15T13:00:00.000Z"
}

Simple, right? Here are the gotchas.

Gotcha 1: You Must Send Zero-Usage Records

This is the one that surprises everyone. If a customer is subscribed but didn’t use your product in the last hour, you still need to send a record with Quantity: 0.

Why? AWS Marketplace uses the absence of metering records as a signal that something is wrong. If you stop sending records for a customer, AWS may flag the subscription for review or pause it. The zero-usage heartbeat tells AWS “this customer is still active, they just didn’t use anything this hour.”

This means your metering job needs to know about all subscribed customers, not just the ones with usage:

const subscribedCustomers = db.customers.getSubscribedCustomers();
const usageMap = aggregateUsageByCustomer(hourStart, hourEnd);

const customersWithRecords = new Set();

// Build records for customers with actual usage
for (const customer of subscribedCustomers) {
    if (usageMap.has(customer.tenantId)) {
        customersWithRecords.add(customer.custId);
        // ... build usage records
    }
}

// Zero-fill for idle customers
for (const customer of subscribedCustomers) {
    if (!customersWithRecords.has(customer.custId)) {
        for (const dimension of dimensions) {
            usageRecords.push({
                Timestamp: hourStart,
                CustomerIdentifier: customer.custId,
                Dimension: dimension,
                Quantity: 0
            });
        }
    }
}

But wait – which dimensions do you send zero records for? You can’t just guess. You need to know the exact set of ExternallyMetered dimensions defined for your product. More on that in Gotcha 3.

Gotcha 2: The Timestamp Is the Hour, Not “Now”

The Timestamp field in each usage record is not when you’re making the API call. It’s the start of the billing hour you’re reporting for.

If your metering job runs at 14:35 UTC, you’re reporting usage for the 13:00-14:00 UTC window. The timestamp must be 2025-03-15T13:00:00.000Z – the start of the previous hour, truncated to the hour boundary.

function getPreviousHourStart() {
    const now = new Date();
    const previousHour = new Date(now);
    previousHour.setUTCHours(previousHour.getUTCHours() - 1);
    previousHour.setUTCMinutes(0);
    previousHour.setUTCSeconds(0);
    previousHour.setUTCMilliseconds(0);
    return previousHour;
}

Get this wrong and you’ll either:

  • Double-bill a customer (reporting the current hour’s usage when the previous hour’s usage was already reported)
  • Get rejected by AWS (timestamps must fall within the last 6 hours)

Your usage aggregation query needs to match this window exactly. Use a half-open interval – greater-than-or-equal to the hour start, strictly less than the hour end:

SELECT acctId, dimension, COALESCE(SUM(usage), 0) as total
FROM usage
WHERE datetime(timestamp) >= datetime(?)    -- 13:00:00
  AND datetime(timestamp) < datetime(?)     -- 14:00:00
GROUP BY acctId, dimension

The < (not <=) on the end boundary is critical. A usage event timestamped at exactly 14:00:00.000 belongs to the next hour’s window, not this one.

Gotcha 3: You Need to Know Your Dimensions at Runtime

When you define your AWS Marketplace product, you configure metering dimensions (e.g., api_calls, storage_gb, users). Some are ExternallyMetered (you report them via BatchMeterUsage), others might be contract-based.

Your metering job needs to know which dimensions are externally metered so it can:

  1. Send zero-usage records for the right dimensions
  2. Not accidentally skip a dimension

You could hardcode them. But then you’d need a code change every time you add a dimension to your product listing. A better approach is to fetch them from the AWS Marketplace Catalog API at startup:

const { MarketplaceCatalogClient, DescribeEntityCommand } = require('@aws-sdk/client-marketplace-catalog');

async function getExternallyMeteredDimensions(productEntityId) {
    const client = new MarketplaceCatalogClient({ region: 'us-east-1' });
    const resp = await client.send(
        new DescribeEntityCommand({
            Catalog: "AWSMarketplace",
            EntityId: productEntityId,
        })
    );

    const details = JSON.parse(resp.Details ?? "{}");
    const dimensions = details.Dimensions || [];

    return dimensions
        .filter(dim => dim.Types && dim.Types.includes('ExternallyMetered'))
        .map(dim => dim.Key);
}

Cache the result in memory for the lifetime of the process. Dimensions don’t change often, and you don’t want to call the Catalog API on every metering cycle. A process restart picks up new dimensions.

One thing to note: the Marketplace Catalog API, like the Metering API, only works in us-east-1. Even if your application runs in another region.

Gotcha 4: The 25-Record Batch Limit

BatchMeterUsage accepts a maximum of 25 usage records per API call. If you have 10 customers and 3 dimensions, that’s 30 records – you need two API calls.

This is easy to overlook in development when you have 1-2 test customers. It breaks in production when you have real customers:

async function sendBatchMeterUsage(usageRecords) {
    const BATCH_SIZE = 25;

    for (let i = 0; i < usageRecords.length; i += BATCH_SIZE) {
        const batch = usageRecords.slice(i, i + BATCH_SIZE);

        const command = new BatchMeterUsageCommand({
            ProductCode: PRODUCT_CODE,
            UsageRecords: batch
        });

        const response = await meteringClient.send(command);
        // Handle response...
    }
}

Send batches sequentially, not in parallel. AWS rate limits the metering API, and you don’t want to deal with throttling errors on top of everything else.

Gotcha 5: Failures Are Silent and Varied

A successful API call doesn’t mean all records were accepted. BatchMeterUsage has three distinct failure modes, and you need to handle all of them:

1. Per-record failures in Results:

The response includes a Results array where each record has a Status. A Status of "Success" means the record was accepted. Anything else – "CustomerNotSubscribed", "DuplicateRecord", etc. – means it wasn’t.

for (const result of response.Results) {
    if (result.Status === 'Success') {
        // Save the MeteringRecordId for your audit trail
        saveSuccessReport(result);
    } else {
        // This record was rejected -- log it, save it, alert on it
        saveFailureReport(result);
    }
}

The MeteringRecordId returned on success is your receipt. Save it. If there’s ever a billing dispute, this is your proof.

2. Unprocessed records:

The response may include an UnprocessedRecords array – records that AWS didn’t even attempt to process. This happens under load or transient issues:

if (response.UnprocessedRecords && response.UnprocessedRecords.length > 0) {
    // These need to be retried
    logger.warn(`${response.UnprocessedRecords.length} records were not processed`);
}

3. Full batch failure (network/API error):

The entire send() call throws. None of the records were submitted:

try {
    const response = await meteringClient.send(command);
    // handle Results + UnprocessedRecords
} catch (error) {
    // None of the 25 records in this batch were submitted
    // Log all of them as failed
    for (const record of batch) {
        saveErrorReport(record, error.message);
    }
}

The key insight: you need an audit table. Every record you submit should be saved with its outcome – success (with MeteringRecordId), failure (with status), or error (with error message). Without this, you’re flying blind.

CREATE TABLE metering_reports (
    id INTEGER PRIMARY KEY,
    customer_identifier TEXT NOT NULL,
    dimension TEXT NOT NULL,
    quantity INTEGER NOT NULL DEFAULT 0,
    metering_timestamp DATETIME NOT NULL,
    metering_record_id TEXT,
    status TEXT NOT NULL DEFAULT 'success',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

This table is your billing truth. Query it to answer “what did we report to AWS for customer X in March?” or “which records failed last week?”

Gotcha 6: Not all regions support the Metering API

As of this writing, BatchMeterUsage is supported in the following AWS Regions (from here):

Commercial Regions:

eu-north-1, me-south-1, ap-south-1, eu-west-3, ap-southeast-3, us-east-2, af-south-1, eu-west-1,me-central-1, eu-central-1, sa-east-1, ap-east-1, ap-south-2, us-east-1, ap-northeast-2, ap-northeast-3, eu-west-2, ap-southeast-4, eu-south-1, ap-northeast-1, us-west-2, us-west-1, ap-southeast-1, ap-southeast-2, il-central-1, ca-central-1, eu-south-2, eu-central-2

China Regions:

cn-northwest-1

Gotcha 7: Two versions of the API

There are actually two versions of the BatchMeterUsage API but with the same name. One version takes in the CustomerIdentifier and ProductCode and another version takes in CustomerAWSAccountId (instead of CustomerIdentifier) and LicenseArn (instead of ProductCode). The first version is what is widely used but you have to use the second version starting June 1, 2026 if you want to use Concurrent Agreements. Read about both of them including code samples here.

Putting It All Together

Here’s the overall structure of a production metering job:

Every hour:
  1. Compute the time window (previous hour start/end)
  2. Fetch all subscribed customers from your database
  3. Aggregate usage from the usage table, grouped by (customer, dimension)
  4. Build usage records:
     a. Real usage records for customers who had activity
     b. Zero-usage records for idle customers (for all ExternallyMetered dimensions)
  5. Split into batches of 25
  6. Send each batch sequentially
  7. Save every result to the audit table (success, failure, or error)

And separately:

On new subscription (subscribe-success SQS event):
  - Send an immediate zero-usage record for all dimensions
  - This registers the customer with AWS Metering without waiting for the next hourly job

Takeaway

BatchMeterUsage is deceptively simple. The API call is one function. But the operational concerns around it – zero-usage heartbeats, hourly timestamp semantics, batch limits, three failure modes, dimension discovery, identifier mapping – are where the real complexity lives.

If you’re implementing this for the first time, budget more time than you think. And build the audit table from day one. When a customer questions their bill three months from now, you’ll be glad you did.


I’ve packaged a production-tested implementation of all of this – metering, auth, entitlements, and the rest of the AWS Marketplace plumbing – into a self-hosted Node.js gateway kit. If you’re listing a SaaS product on AWS Marketplace, check it out here.

Posted in Computers, programming, Software | Tagged , , | Leave a comment

Essofore Semantic Search — Self-Hosted RAG Infrastructure That Keeps Your Data in Your VPC

Upload documents. Search in plain English. Your data never leaves your AWS account.

Most vector search infrastructure has a hidden problem: your data lives somewhere else. Whether it’s Pinecone’s servers or a managed Elasticsearch cluster, your proprietary documents are outside your control.

Essofore is different. It deploys as an AMI directly into your AWS VPC. Your documents never touch our servers or anyone else’s. And unlike Elasticsearch — which charges more as your data grows because vectors must be loaded into RAM — Essofore’s cost stays flat no matter how much you index for a given EC2 instance.

Who this is for:

  • Developers at healthcare, fintech, or legaltech companies who can’t send proprietary documents to a SaaS vendor
  • Teams getting unexpected bills from Elasticsearch or managed vector DBs as their corpus grows
  • Developers who want RAG search without becoming an ML expert — no embedding knowledge required

What makes it different:

  • Data sovereignty: 100% self-hosted in your AWS account. No third party ever sees your data.
  • Transparent pricing: $0.55/hour + Cost of EC2 independent of the data volume
  • Zero ML expertise required: Upload PDFs, Word docs, HTML. Essofore handles chunking, embeddings, vector storage, and search automatically.
  • Enterprise-grade simplicity: One AMI. One systemctl command. Ready to serve queries.

Typical use cases:

  • Internal enterprise search over company knowledge bases
  • RAG retrieval layer for LLM-powered applications
  • HIPAA/GDPR-compliant AI search where data residency matters

Get Started on AWS.

Posted in Computers, programming, Software | Leave a comment

Note to self: For God’s sake stop renaming things

There are only two hard things in Computer Science: cache invalidation and naming things. — Phil Karlton

I don’t think there is anyone on the planet who renames things as much as I do. Stop it. Seriously. Its not worth it.

It looks innocuous. But it isn’t.

I had a project that was working just fine but being me I renamed the directory and all of a sudden everything stopped working. Turns out it was a Python project using uv (you run into same problem if you use poetry). uv hardcodes the path in .venv/bin/activate. Look for this line:

VIRTUAL_ENV='/path/to/.venv'

So if you rename a directory and then run source .venv/bin/activate or any other uv command, it will stop working!

It doesn’t end there. I was using uvicorn and the path is hardcoded in that too. What makes it hard to debug is that the errors you get are extremely cryptic like File not found when you can see it right in front of your eyes.

Posted in Computers, programming, Software | Leave a comment

ETFs vs. Mutual Funds

This post is AI generated

Are ETFs more tax-efficient than their equivalent mutual funds?

Yes, ETFs are generally more tax-efficient than their equivalent mutual funds, especially in taxable brokerage accounts. This advantage holds for both passive (index-tracking) and many active strategies, though the gap is most pronounced in equity funds.

Why ETFs Tend to Be More Tax-Efficient

The key difference comes down to structure and how shares are created/redeemed, not the underlying investments or tax rules themselves (both are taxed the same way on dividends and realized capital gains when you sell).

  • Mutual funds: When investors redeem shares, the fund often sells underlying securities for cash to pay them out. If those sales generate gains (common in rising markets or with high turnover), the fund must distribute those capital gains to all shareholders at year-end—even if you didn’t sell anything. You owe taxes on those distributions (usually long-term capital gains rates).
  • ETFs: Most use an in-kind creation/redemption process. Large institutional investors (authorized participants) exchange baskets of securities for ETF shares (creation) or vice versa (redemption). No cash changes hands at the fund level in these large blocks, so the ETF avoids selling securities and realizing gains that would need to be distributed. Investor trading on the exchange also doesn’t force the fund to sell holdings.

This results in far fewer (or zero) capital gains distributions from ETFs.

Real-World Data on Tax Efficiency

Recent figures confirm the structural edge:

  • In 2025, only 7% of ETFs distributed capital gains, compared to 52% of mutual funds. Long-term averages (since 2016) are about 9% for ETFs vs. 53% for mutual funds.
  • Among passive funds: Just 4% of passive ETFs distributed gains vs. 41% of passive mutual funds.
  • Active ETFs also outperform active mutual funds on this metric (9% vs. 53% in 2025).

Even when ETFs do distribute gains, the amounts are typically much smaller. Over multi-year periods, this can mean meaningful after-tax return differences for buy-and-hold investors in taxable accounts.

Other Tax Considerations

  • Dividends and interest: Both ETFs and mutual funds distribute these, and they’re taxed similarly (qualified dividends at favorable rates for many investors).
  • Your own sales: You pay capital gains tax only when you sell shares in either vehicle. ETFs trade like stocks (intraday), giving you more control over timing, but this doesn’t create fund-level distributions.
  • Exceptions and caveats:
    • Vanguard’s structure: Many Vanguard mutual funds have an ETF share class that’s essentially the same fund; tax efficiency benefits can flow to both.
    • Bond/fixed-income funds: The advantage is smaller because income (interest) is the main taxable event anyway, and turnover is often lower.
    • Highly active or niche strategies: Some mutual funds use tax-management techniques (loss harvesting, etc.) that can narrow the gap, but ETFs still usually win on average.
    • Tax-advantaged accounts (IRAs, 401(k)s, Roths): Tax efficiency doesn’t matter here—gains and distributions aren’t taxed until withdrawal (or never, in Roths). Choose based on fees, liquidity, or other features.
    • ETFs aren’t immune: They can still have capital gains in rare cases (e.g., major index changes, mergers, or certain commodity/actively managed funds).

Bottom Line

For taxable accounts, equivalent ETFs (same index or strategy) are typically the more tax-efficient choice due to minimized unwanted capital gains distributions. This can compound into higher after-tax returns over time without any extra effort on your part. In tax-sheltered accounts, the difference is irrelevant.

Always check a specific fund’s history of capital gains distributions (available on fund fact sheets or tax guides) and consult a tax advisor for your situation, as individual results depend on your tax bracket, holding period, and market conditions.

Posted in Money | Leave a comment

Site Refresh

Before and After:

PageSpeed Insights (New):

Please help me evaluate the old vs. new site by giving your feedback below:

Old: https://staging.nuvoice.ai/

New: https://www.nuvoice.ai/

On a scale of 1-10 where:

  • 1–3 = Barely functional / confusing / unprofessional
  • 4–6 = Basic but has clear issues (common for early drafts)
  • 7–8 = Solid and competitive (ready for public launch with minor tweaks)
  • 9–10 = Excellent, polished, high-converting landing page

How would you rate each of them?

Posted in Computers, programming, Software | Leave a comment

Building the AWS Marketplace SaaS Fulfillment Flow (ResolveCustomer and Beyond)

When a customer clicks “Subscribe” on your AWS Marketplace listing, AWS redirects them to your application with a token. Your job is to validate that token, create a tenant, and get the customer into your product. It sounds like a simple redirect. It isn’t.

The fulfillment flow touches CORS, cross-site cookies, JWT session management, race conditions with SQS, and a token validation API that only works in one region. I’ve built this flow for 4 AWS Marketplace SaaS products. Here’s everything I wish someone had told me upfront.

How the Flow Works

The end-to-end sequence looks like this:

1. Customer clicks Subscribe on AWS Marketplace
2. AWS shows a confirmation page, customer clicks "Set Up Your Account"
3. AWS POSTs an HTML form to your fulfillment URL with a registration token
4. Your server calls ResolveCustomer to validate the token
5. You create a tenant record in your database
6. You redirect the customer to your signup page
7. Customer creates their admin account (email + password)
8. Customer is now in your product

Steps 3 through 7 are where all the complexity lives.

Step 1: Receiving the POST from AWS

When the customer clicks “Set Up Your Account,” AWS submits an HTML form POST from aws.amazon.com to your fulfillment URL. The body is URL-encoded with two fields:

Field Description
x-amzn-marketplace-token Opaque registration token. You’ll pass this to ResolveCustomer.
x-amzn-marketplace-offer-type "free-trial" for trial offers. Absent for paid subscriptions.

This is a cross-origin POST from Amazon’s domain to yours. That means you need CORS headers:

const cors = require('cors');

router.post('/', cors({
    origin: process.env.NODE_ENV === 'production'
        ? 'https://aws.amazon.com'
        : '*'
}), async (req, res) =&gt; {
    const regToken = req.body['x-amzn-marketplace-token'];
    const offerType = req.body['x-amzn-marketplace-offer-type'] || 'paid';
    // ...
});

Lock the CORS origin to https://aws.amazon.com in production. In development, allow anything so you can test with a mock.

One nuance: the CORS check happens in the browser, not on your server. Your server has no way to enforce CORS – it just sets the Access-Control-Allow-Origin header and the browser decides whether to allow the response. In practice, since your response is a 303 redirect, browsers follow it regardless of CORS. But setting the header is still good practice.

Step 2: Calling ResolveCustomer

The token from AWS is opaque. You can’t decode it yourself. You must call ResolveCustomer to exchange it for the customer’s identity:

const { MarketplaceMeteringClient, ResolveCustomerCommand } = require('@aws-sdk/client-marketplace-metering');

const client = new MarketplaceMeteringClient({ region: 'us-east-1' });

const response = await client.send(
    new ResolveCustomerCommand({ RegistrationToken: regToken })
);

const acctId = response.CustomerAWSAccountId;   
const custId = response.CustomerIdentifier;     
const productCode = response.ProductCode;       

Three things come back:

  • CustomerAWSAccountId – The customer’s 12-digit AWS account ID. This is your primary tenant identifier.
  • CustomerIdentifier – A shorter identifier used by the Metering and Entitlement APIs. You need both.
  • ProductCode – Your product’s code. Validate this matches your configured product code to prevent cross-product token replay.

Validate all three fields are present, and verify the product code matches yours:

if (!acctId || !custId || !productCode) {
    return renderErrorPage(res, 'Registration failed. Please try again from AWS Marketplace.');
}
if (productCode !== PRODUCT_CODE) {
    return renderErrorPage(res, 'Invalid product code.');
}

Return HTML error pages here, not JSON. The response goes to a browser that was just redirected from Amazon.

Step 3: Handling Returning Customers

Not every POST to your fulfillment URL is a new customer. A customer might click through from AWS Marketplace again after they’ve already registered. You need to handle three cases:

const existingTenant = db.customers.getByAwsAcctId(acctId);

if (existingTenant &amp;&amp; existingTenant.email) {
    // Fully registered. Redirect to the app.
    return res.redirect(303, '/app');
}

if (existingTenant &amp;&amp; !existingTenant.email) {
    // Tenant exists but admin never completed signup.
    // Give them a fresh registration session.
    return createRegistrationSession(custId, acctId, productCode, res);
}

// Brand new customer. Create tenant and start registration.
const subscriptionStatus = deriveSubscriptionStatus(custId);
db.customers.add(acctId, custId, offerType, subscriptionStatus);
createRegistrationSession(custId, acctId, productCode, res);

The middle case – tenant exists but no email – happens when a customer clicked through from AWS, you created the tenant row, but they closed their browser before completing the signup form. Give them a fresh session and let them try again.

Step 4: The Registration Session

After creating the tenant, you need to get the customer to a signup page where they create their admin account. But you need to carry the registration context (who this AWS customer is) from the POST handler to the signup page without exposing it in a query string.

The solution: a short-lived JWT stored in an httpOnly cookie.

const crypto = require('node:crypto');
const jwt = require('jsonwebtoken');

function createRegistrationSession(custId, acctId, productCode, res) {
    const jti = crypto.randomUUID();

    // Store in the database for one-time-use enforcement
    db.registrationSessions.store(jti, custId, acctId);

    // Sign a short-lived JWT
    const regJwt = jwt.sign(
        { jti, acctId, pc: productCode },
        JWT_SECRET,
        {
            algorithm: 'HS256',
            audience: 'awsmp-register',
            issuer: APP_NAME,
            expiresIn: REG_SESSION_TTL_SEC
        }
    );

    // Set it as an httpOnly cookie
    res.cookie('reg', regJwt, {
        httpOnly: true,
        secure: true,
        sameSite: 'lax',
        path: '/',
        maxAge: REG_SESSION_TTL_SEC * 1000,
    });

    return res.redirect(303, '/admin/signup');
}

The database table backing this:

CREATE TABLE registration_sessions (
    jti TEXT PRIMARY KEY,
    cust_id TEXT NOT NULL,
    tenant_id TEXT NOT NULL,
    used INTEGER NOT NULL DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Three things to notice:

1. sameSite: 'lax', not 'strict'. This is the gotcha that will cost you hours of debugging. The customer is being redirected from aws.amazon.com to your domain. With sameSite: 'strict', the browser won’t send the cookie on a cross-site redirect. lax allows it on top-level navigations (which a 303 redirect is). Your main auth cookie should still use strict – only the registration cookie needs lax.

2. The session is one-time-use. The used flag prevents the same registration session from being consumed twice. After the customer completes signup, mark it used. Without this, someone could replay the reg cookie to create additional admin accounts.

3. Dual expiry. The JWT has its own expiry (expiresIn), and the middleware also checks the database created_at against the TTL. Belt and suspenders. If the JWT secret is compromised, the database check still protects you.

Step 5: The Admin Signup

When the customer lands on the signup page, the reg cookie is present. Your signup endpoint validates it:

function requireRegistrationSession(req, res, next) {
    const cookie = req.cookies?.reg;
    if (!cookie) {
        return res.status(403).send('Start from AWS Marketplace to register.');
    }

    try {
        const payload = jwt.verify(cookie, JWT_SECRET, {
            audience: 'awsmp-register',
            issuer: APP_NAME
        });

        const row = db.registrationSessions.get(payload.jti);

        if (!row || row.used) {
            return res.status(403).send('Registration link expired.');
        }

        // Check TTL against database timestamp (belt + suspenders)
        const sessionAge = Date.now() - new Date(row.created_at).getTime();
        if (sessionAge &gt; REG_SESSION_TTL_SEC * 1000) {
            return res.status(403).send('Registration link expired.');
        }

        req.registration = { jti: payload.jti, awsAccountId: row.tenant_id };
        next();
    } catch (e) {
        return res.status(403).send('Invalid registration session.');
    }
}

Then the admin signup handler creates the user account, sets the auth cookie, and clears the registration cookie – all in a single database transaction:

router.post('/admin-signup', requireRegistrationSession, async (req, res) =&gt; {
    const { email, password } = req.body;
    const acctId = req.registration.awsAccountId;

    db.transaction(() =&gt; {
        db.users.create({
            tenantId: acctId,
            email,
            hash: bcrypt.hashSync(password, SALT_ROUNDS),
            role: 'admin'
        });
        db.customers.updateEmail(acctId, email);
        db.registrationSessions.markUsed(req.registration.jti);
    });

    // Issue the real auth cookie (sameSite: strict this time)
    const token = jwt.sign({ acctId, email, role: 'admin' }, JWT_SECRET, {
        expiresIn: JWT_EXPIRATION_HOURS + 'h'
    });
    res.cookie('token', token, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
    });

    // Clear the registration cookie
    res.clearCookie('reg', { path: '/', sameSite: 'lax', secure: true });

    res.status(201).json({});
});

Notice the cookie upgrade: the reg cookie was sameSite: 'lax' (needed for the cross-site redirect). The token auth cookie is sameSite: 'strict' (it’s only ever used same-site from now on). The customer transitions from a loose-security registration context to a tight-security authenticated context.

Step 6: The SQS Race Condition

There’s one more thing. When a customer subscribes, AWS also sends a subscribe-success message to your SQS queue. These two events – the browser redirect and the SQS message – are completely independent. The SQS message can arrive before the customer visits your registration URL.

If your SQS handler tries to UPDATE the tenant’s subscription status but the tenant row doesn’t exist yet, the UPDATE silently affects 0 rows. The SQS message is deleted from the queue. And now you’ve lost the event.

The fix: always save SQS events to an audit table first, then attempt the UPDATE. At registration time, derive the initial subscription status from the event history:

// At registration time (new customer)
const latestEvent = db.subscriptionEvents.getLatestByCustomer(custId);
const subscriptionStatus = latestEvent?.action === 'unsubscribe-success'
    ? 'unsubscribed'
    : 'subscribed';

db.customers.add(acctId, custId, offerType, subscriptionStatus);

I wrote a full blog post about this race condition if you want the details.

The Full Picture

Here’s the complete flow with every security boundary:

AWS Marketplace (aws.amazon.com)
  |
  | HTML form POST (x-amzn-marketplace-token)
  | CORS: Access-Control-Allow-Origin: https://aws.amazon.com
  v
POST /register
  |-- ResolveCustomer (us-east-1) --&gt; acctId, custId, productCode
  |-- Validate productCode matches
  |-- Check: returning customer?
  |     yes + has email  --&gt; 303 redirect to /app
  |     yes + no email   --&gt; fresh registration session
  |     no               --&gt; create tenant, derive subscription status from SQS events
  |-- Sign JWT (audience: awsmp-register, TTL: short)
  |-- Store session in DB (jti, cust_id, tenant_id, used=0)
  |-- Set 'reg' cookie (httpOnly, secure, sameSite: lax)
  |-- 303 redirect to /admin/signup
  v
GET /admin/signup (frontend signup form)
  |
  | User enters email + password
  v
POST /users/admin-signup
  |-- requireRegistrationSession middleware
  |     verify JWT, check DB row exists + not used + not expired
  |-- Transaction:
  |     INSERT user (email, password_hash, tenant_id, role=admin)
  |     UPDATE tenant email
  |     UPDATE registration_session used=1
  |-- Set 'token' cookie (httpOnly, secure, sameSite: strict)
  |-- Clear 'reg' cookie
  |-- 201 Created
  v
Customer is authenticated and in the product

Gotchas Summary

  1. sameSite: 'lax' on the registration cookie. strict blocks the cross-site redirect from Amazon. This is the #1 thing that trips people up.
  2. Validate the ProductCode. Prevents cross-product token replay.
  3. Handle returning customers. They’ll click through from AWS Marketplace again. Don’t error – redirect them.
  4. One-time-use registration sessions. The used flag prevents replay. The database check catches JWTs that are still valid by expiry.
  5. SQS race condition. Save events to an audit table first, reconcile at registration time. Don’t rely on the tenant row existing when SQS events arrive.
  6. Return HTML error pages, not JSON. The customer is in a browser redirected from Amazon. A JSON error response is useless to them.
  7. Free-trial offer type. Capture x-amzn-marketplace-offer-type at registration and store it. You’ll need it later for entitlement gating.

Takeaway

The ResolveCustomer fulfillment flow is the front door to your AWS Marketplace SaaS product. Every customer passes through it exactly once. It touches cross-origin security, cookie semantics, distributed event ordering, and session management – all in a single POST handler.

Get it right once, and you never think about it again. Get it wrong, and your customers can’t use your product.


I’ve packaged a production-tested implementation of this entire flow – plus auth, entitlements, metering, and a built-in admin panel – into a self-hosted Node.js gateway kit. If you’re listing a SaaS product on AWS Marketplace, check it out here.

Posted in Computers, programming, Software | Tagged | Leave a comment

How to protect Cloudfront hosted website from malicious traffic?

Problem

I was hosting a static homepage on Cloudfront and was surprised to see tens of thousands of requests to it per day:

These requests are coming from malicious bots. How do we confirm?

Investigation

The best way to be able to debug issues like this is to enable Cloudfront Standard logs and then analyze in Amazon Athena:

1. Enable CloudFront Standard Logs

Enable CloudFront Standard Logsnot available in free-plan associated with flat-rate pricing. So choose PAYG pay-as-you-go plan. This will unlock many other features and for low traffic websites is better in terms of cost than flat-pricing. However, note that once you are on the Free-Plan you cannot change to PAYG! You have to cancel the plan first [1]

  • Go to the CloudFront Console > Distributions > [Your ID].
  • Under General tab, click Edit.
  • Find Standard logging, turn it on, and select an S3 bucket to store them.
  • Wait: It takes about an hour for logs to start appearing.

2. Analyze the Logs with Amazon Athena

Once you have logs, don’t try to read them manually—they are messy. Use Amazon Athena to run a SQL query against the log files in S3.

Run this query to find the top “offenders”:

SELECT client_ip, count(*) as request_count, uri_path, user_agent
FROM cloudfront_logs
GROUP BY client_ip, uri_path, user_agent
ORDER BY request_count DESC
LIMIT 20;
  • If you see one IP with 10k requests: It’s a bot. You can block it via AWS WAF.
  • If you see requests for .php or /admin: It’s a vulnerability scanner.
  • If you see a specific image file being requested: Someone has likely embedded your image on their high-traffic site.

Analyzing 4xx traffic

It would be good to know what paths are causing 4xx. These are the paths malicious bots are trying to hit and we can create a rule (Solution 2) that blocks traffic based on what path is being requested. If we don’t have a /wp-admin on our site, anyone trying to access it must be a bot. Unfortunately, you can’t see a list of 4xx URLs/paths on the free-plan. What you can do as a best-effort is go to Reports and Analytics -> Popular Objects tab and sort it by 4xx descending:

Solution 1: Rate-limit

Goto your Cloudfront distribution in AWS console. Then from the Security tab select Manage Rules

By default you will see:

Click on Add Rule. Choose Rate-based rule and click Next

Configure as follows. Change rate-limit as you like. Click on Add Rule

Solution 2: BETTER: Block traffic trying to access bad paths (not available on free-plan)

Many bots try to access paths like /.env, /wp-admin etc. If you are hosting a static website you know these paths do not exist and anyone trying to access them is a malicious bot. So instead of rate-limiting, a better option is to just block anyone who tries to hit these paths that otherwise cause 4xx. Tip: Look for paths that cause 4xx errors and add them to the list. Goto Manage Rules and select Custom Rule

After that you would select URI path in below:

and block malicious bots hitting your website.

How much do Cloudfront logs cost?

Analyzing these logs is very affordable for a site with low traffic volume. CloudFront doesn’t charge you to generate the logs, but you pay for storing them in S3 and querying them with Athena.

Based on your current traffic (~30k requests/day), here is the cost breakdown:

1. S3 Storage (Negligible)

CloudFront logs are small. Even with 30,000 requests per day, you’re likely generating less than 100MB of logs per month.

  • Cost: Approximately $0.01 per month.
  • Math: S3 Standard is about $0.023 per GB. You aren’t even hitting 1 GB.

2. Athena Queries (Cheap)

Athena charges based on the amount of data scanned.

  • Cost: $0.01 per query (minimum charge).
  • Math: Athena charges $5.00 per TB scanned. Since your total logs for the month are likely under 1 GB, every query you run will cost the minimum scan fee (10MB), which is effectively a fraction of a penny.

3. S3 Requests (The “Hidden” Cost)

Every time CloudFront “puts” a log file into your S3 bucket, it’s an S3 PUT request.

  • Cost: Approximately $0.05 – $0.10 per month.
  • Math: S3 charges $0.005 per 1,000 PUT requests. CloudFront delivers logs in batches every few minutes.

Total Estimated Cost

For a site seeing ~1 million requests a month (your current trajectory), the total cost to log and debug this will be less than $0.50 USD per month.

Pro Tip: If you want to keep it free, set an S3 Lifecycle Policy on your log bucket to automatically delete files older than 7 or 14 days. This prevents “log bloat” from costing you money a year from now.

Flat-Rate Pricing Plans

Below are details of flat-rate pricing plans. Tip: Choose Pay-As-You-Go for low-traffic websites like homepages. It won’t be completely free but you get more features and worth the cost.


This post has been written with help from Gemini

Posted in Computers, programming, Software | Tagged , | Leave a comment

Cannot connect to localhost from Chrome

Ran into this issue where all of a sudden I could not connect to localhost from Chrome. It was working and then all of a sudden it stopped working. It (Chrome) just hangs waiting for response from server. I was able to make curl requests successfully. What gives? Turned out to be a WSL issue (bug) in my case. Sometimes something inside WSL crashes and it breaks the networking. The fix is to shutdown WSL and start it again:

wsl --shutdown

The curl request was working because I was running it inside WSL. If I ran curl from Windows it gave same error.

Lately I have been seeing this happen more often so making a note to self.

Posted in Computers, programming, Software | Tagged | Leave a comment