Edge routing on AWS CloudFront
Adapter version 0.6.16 (the router passes your origin the Host header your own origin request policy forwards, tells it the public hostname in X-Norg-Public-Host, and reads your pages through your own distribution rather than contacting your server directly — whichever origin request policy you use). The product argument, the cloaking question and the comparison across CDNs are in the edge routing white paper.
The diagrams
The path a request takes
GET example.com/products/widget/api/*, /checkout … never reach our code and are never billed. Assets keep a 24 h edge cache.edge-router-lambda.cjsLambda@Edge · nodejs24.x · 192 MB · 30 s ceiling · 20 s watchdog- Every request: read the config from origin custom headers, deleting each one in the same pass.
- Ordinary browser, or a search crawler: return the request unchanged and stop here — no lookup, no call to NORG or AWS.
- Bot-shaped only: on a cold container, fetch the site key from the regional Secrets Manager replica (1.5 s) and then the policy feed from NORG (5 s). Each is cached on its own clock afterwards — the key for 15 min, the feed for 1 h, refreshed inline once stale.
- Then classify: the operator must be enabled, and the source address must verify against published CIDRs.
- On a match: fetch the mirror, passing one
X-Norg-Visitheader that describes the visit. The router sends no telemetry and waits on none.
put-secret-value.X-Norg-Edge, written private, no-store. Strip under 120 words → origin instead.X-Norg-EdgeAI crawler: structured, same URL, 200Where it runs
Everything is named us-east-1; only the heartbeat actually executes there. The router replicates to CloudFront's regional edge caches and runs in the one nearest the request, reading the site key from that region's replica.
edge-router-lambda.cjsLambda@Edge · origin request · every page requestheartbeat-lambda.cjsRegional Lambda · 128 MB · 10 sEventBridge Scheduler every 30 min → POST to NORG (3 s). Proves a zero-traffic install is alive.DIGESTS.json. Rollback = stack update naming the previous version. Propagation 5–15 min./aws/lambda/us-east-1.<fn>Created per region; streams appear nearest the edge that served the request.x-norg-disabled- What it is. An origin custom header is a name and value CloudFront attaches to every request it forwards to an origin. It is part of the distribution's configuration, not something a visitor sends. Ours is
x-norg-disabled, defaultfalse. - Where it lives. In the CloudFront console: your distribution → Origins → your origin → Custom headers. In the CloudFormation template it is the
OriginCustomHeaderslist on the origin, fed by the stack parameterEdgeDisabled, alongside the otherx-norg-*settings. It is entirely in your AWS account. NORG cannot flip it. - How the router reads it. On every page request the origin-request Lambda lifts each configuration header out of the origin's custom headers on the request it was handed and deletes it, so your web server never sees them.
x-norg-disabled: trueis the first check in its passthrough test, before the browser check, search-crawler detection, the site key or the policy feed, so the request is returned unchanged and CloudFront fetches your origin as if we were not installed. - How to flip it. Update the stack with
EdgeDisabled=true, or edit the header value in the console; set it back tofalsethe same way. Both are a distribution update, which propagates in 5–15 minutes. There is no instant off on CloudFront. The functions stay deployed, so turning it back on is the same edit in reverse.
- origin request
- The point where CloudFront runs code just before fetching from the origin, only on a request its cache cannot answer. It is the one trigger this install uses, and because no page is cached at the edge it fires on every page request.
- cache behaviour
- A CloudFront rule that matches a path pattern and says how to cache it and which functions to attach. An install is mostly cache behaviours; a behaviour with no function attached is how a path is excluded.
- origin custom header
- A header the distribution adds to every request it sends toward the origin. Non-secret configuration travels this way; the router reads each one and deletes it.
- container
- One warm copy of the Lambda. Its two caches, the site key and the policy feed, live only as long as it does. Cold means a fresh one with nothing cached yet.
- passthrough
- The router returning the request object unchanged, so CloudFront fetches your origin exactly as it would without us.
- policy feed
- The per-site list of crawler patterns, operator settings and verified address ranges the router fetches from NORG and caches for up to an hour. A human request never fetches it.
- mirror, NORG content service (the receptionist)
- The agent-facing copy of a page, and the NORG endpoint the router fetches it from, which holds the mirror cache and records the visit from the header the router sends.
- MCP
- Model Context Protocol, the endpoint an agent uses to query a site directly. Requests to MCP paths are the one thing the router relays to NORG with a body.
It must never break your site. Every failure path ends at your origin response. On this platform that is structurally easy: passthrough is the request object returned unchanged, so CloudFront fetches your origin exactly as it would without us.
Search engines see what humans see. Traditional search crawlers are returned the origin before the router classifies anything, with no lookup made, and no configuration reaches the search-engine check itself.
What runs, and when
Two artifacts: the router on origin request, and a scheduled heartbeat that exists only because Lambda@Edge has no cron. Nothing runs at viewer request and nothing runs at origin response.
| Artifact | Trigger | Runtime and limits | What it is for |
|---|---|---|---|
| edge-router-lambda.cjs | Origin request (every page request) | Lambda@Edge, nodejs24.x 192 MB, 30 s platform ceiling, 20 s internal watchdog | The whole decision sequence, the mirror fetch, the strip and the MCP relay. It sends no telemetry of its own: the visit travels as one header on the mirror fetch and the NORG content service records it. |
| heartbeat-lambda.cjs | EventBridge Scheduler, every 30 minutes | Regional Lambda (us-east-1), nodejs24.x 128 MB, 10 s | Proves a zero-traffic install is alive. Lambda@Edge has no cron of any kind, so liveness cannot come from the router. |
No page is cached at the edge, so every page request reaches the origin-request Lambda. It reads its configuration out of the origin custom headers (non-secret settings the distribution attaches to every request it sends toward the origin) and deletes them in the same pass. Then it looks at the user agent. An ordinary browser on an ordinary path is returned at once, and so is a traditional search crawler: no lookup, no call to NORG, no call to AWS, just the request handed back for CloudFront to fetch your origin as if we were not installed.
Only a bot-shaped request goes further. On a cold container it makes two lookups before it can decide: the site key from Secrets Manager, read from the replica in the region that ran the function, and then the policy feed, the per-site list of crawler patterns, operator settings and verified address ranges it fetches from NORG. Both are cached afterwards. It classifies, checks the operator is enabled, verifies the source address, and then either returns the request for CloudFront to send to your origin, returns a generated response it built from the mirror, or rewrites the request to point at the NORG content service (the receptionist) when the mirror is too large to inline. The mirror fetch carries one extra header describing the visit, and the content service records it; the router itself sends no telemetry and waits on none.
A request is treated as an ordinary browser only when its user agent is confidently one: a Mozilla/5.0 prefix, a real engine token, none of the words a crawler or scripted client carries, and no Signature-Agent header. Everything else, an absent user agent included, takes the full sequence. The two directions of error are not symmetric: a human wrongly treated as bot-shaped costs one policy lookup on a cold container and is then passed through anyway, while an agent wrongly treated as a browser would be handed your origin with the router never having looked. So the test is deliberately lopsided toward the second case never happening, and the set of requests that go further is a superset of anything the router might divert.
Configuration and the site key
Lambda@Edge supports no environment variables. That single AWS restriction shapes this entire area, and it is why the answer is not simply "put it in the environment like the heartbeat does".
The site id, the NORG API and receptionist URLs, the environment name and the feature flags arrive as origin custom headers on the distribution. readConfig lifts them into a plain object and deletes each one from request.origin.custom.customHeaders in the same pass, whether or not it held a value, so a half-configured install cannot leak through a header nobody recognised as populated.
That deletion is what keeps them off your own web server. The same scrub is applied again to whatever object is about to be returned, including on the error and oversized-response paths, so there is no exit from the function that carries a config header onward.
The key is not in the distribution. It lives in a Secrets Manager secret in your own account, created in us-east-1 alongside the function and replicated by the stack to the other twelve regions Lambda@Edge runs in, so the function reads it from the region it is running in rather than across the world. The distribution carries only that secret's ARN in the x-norg-secret-arn header, which is an address rather than a credential. The router fetches it lazily: a human request or a search crawler never fetches it at all, because the browser check runs first; a bot-shaped request on a cold container makes two lookups before its first classification, the key and then the policy feed. It is cached in the container for fifteen minutes. Reading it needs secretsmanager:GetSecretValue on that one ARN, granted by a single inline policy on each of the two execution roles, and every read is recorded in CloudTrail.
Rotation is one call, put-secret-value, with no redeploy. The edge picks the new value up within the cache window and the heartbeat on its next run. The fifteen-minute time-to-live is deliberate: the AWS reference pattern for this caches for the whole container lifetime, which means a rotated secret can stay live indefinitely on a warm container.
A health probe arrives as the request header x-norg-edge-check. Its value is compared against the probe token the distribution supplies in the x-norg-probe-token custom header, which authorises reading install status and nothing else. That token is derived from your site key rather than stored, so rotating the key rotates it too. The inbound header is captured and stripped in the same pass as the config headers, so a mistyped probe cannot reach your origin either. Ask us for your site's value; a self-service surface for it is not built yet.
Failure behaviour, and every bound that enforces it
One rule governs the code: it must never break the site. On this platform that is easier to guarantee than on most, because "serve the origin" is not a fetch the router performs. It is the request object, returned unchanged, for CloudFront to carry out.
A Cloudflare worker that decides to pass through has to fetch your origin itself, so a bug in that fetch is a bug in your site. The origin-request contract here is different: the function returns either a response or a request, and returning the request means "carry on as you would have without me". CloudFront then applies its own retries, its own origin timeouts and its own shield. The top-level catch returns exactly that.
| Bound | Value | What it protects |
|---|---|---|
| WATCHDOG_MS | 20,000 ms | Races the whole pipeline. Sits under the platform 30 s so a slow call degrades to your origin rather than becoming a 502. |
| PATTERN_FETCH_TIMEOUT_MS | 5,000 ms | The policy feed, fetched once per cold container and then cached for the hour. Only a bot-shaped request ever reaches this call. |
| Stale feed refresh | 2,000 ms | A container whose feed has aged past its TTL refreshes it inline on the next bot-shaped request and serves the stale copy if that budget expires. A human request never waits on it. |
| MIRROR_FETCH_TIMEOUT_MS | 4,000 ms | Reading a rendered page from the NORG content service. The visit header rides on this call. |
| CONTROL_CALL_TIMEOUT_MS | 3,000 ms | The scheduled heartbeat Lambda’s own POST, the one foreground call to NORG that is not a mirror fetch. |
| MCP_FORWARD_TIMEOUT_MS | 3,000 ms | The one request relayed to NORG with a body. |
| Secrets Manager fetch | 1,500 ms | Once per container, cached 15 minutes, read from the replica in the region that ran the function. Failure returns null, which means passthrough. |
| UNENTITLED_TTL_MS | 60,000 ms | How long a refused or unreachable feed verdict is remembered, so a paused install costs its visitors no blocking probe on every request. |
| PATTERN_DEFAULT_TTL_MS | 3,600,000 ms | How long a container serves its cached policy feed. |
| MAX_INLINE_MIRROR_BYTES | 870,400 bytes | Above this the mirror is served by repointing the origin at the NORG content service instead of generating a response. |
| MAX_GENERATED_BODY_BYTES | 921,600 bytes | CloudFront's own cap on a generated response. Exceeding it degrades to the origin, never to an error. |
| STRIP_WORD_FLOOR | 120 words | A strip that leaves less than this is discarded and the unmodified origin is returned. |
NORG unreachable or the feed refused: the container keeps its last known-good policy, and an install that has never authenticated does nothing at all. Secrets Manager unreachable: the key resolves to null and the request passes through. Mirror missing, too large, or too thin after stripping: your origin. Watchdog expiry: your origin. Anything thrown anywhere: your origin. There is no path that returns an error page of ours, and the router sends no telemetry of its own, so nothing about analytics can hold or fail a response.
The one failure the code cannot degrade past sits above it: if a region's Lambda@Edge concurrency quota is exhausted, CloudFront answers 503 for every page request in that region until load falls. The cost panel under Distribution shape says how the install surfaces that quota and what bounds it.
Distribution shape, caching and telemetry
An install is mostly cache behaviours. What they are, in what order, and which managed policies they use, decides both correctness and cost.
MCP paths (the Model Context Protocol endpoints an agent calls, such as /.well-known/mcp.json) first, then dynamic routes, then framework asset prefixes, then static suffixes, then the default. Order matters because CloudFront takes the first match: an asset suffix placed above a dynamic route would swallow it. MCP behaviours are separated so the default behaviour can run with request bodies switched off. Only the MCP paths need a body, and forwarding one everywhere would put your visitors' POST payloads through our function for no reason.
The default behaviour and the MCP behaviours move to CloudFront's managed CachingDisabled policy, so nothing they serve is ever stored at the edge. Your existing origin request policy is kept: it decides which Host header your origin receives, and the router passes that Host through unchanged. Only a behaviour with no origin request policy gets one, and it is a managed one chosen to keep what your origin already received: AllViewer if you forwarded the visitor's Host, AllViewerExceptHostHeader if you did not. No custom cache, origin request or response-headers policy is written into the distribution — AWS flat-rate plans (Free, Pro, Business, Premium) refuse to associate a custom origin request policy. The asset carve-outs keep a 24-hour default TTL, because an image is the same bytes for everyone.
The install records your cache and origin request settings before it changes anything, and an uninstall puts them back exactly. Changing which Host your origin sees is the one thing it must never do: a WordPress site reached under its public name redirects to itself if it is handed its server's own name, and a host-routed origin answers a Host it does not expect with 403.
The router is an origin-request function, and CloudFront only runs one on a request it cannot answer from its cache. Earlier versions kept human pages cacheable and partitioned the cache by caller so that agents still reached the router; that took two more functions and a custom cache policy, and each was a place to get wrong. A cache policy with a minimum TTL above zero, for instance, overrides private, no-store on CloudFront, which would have let one bot-shaped request pin your origin page in front of every crawler that followed.
Since 0.6.0 there is no page cache at the edge at all. Every page request reaches the router, a human is handed straight through, and your origin answers as it always did. The origin's own Cache-Control still reaches the browser untouched; only CloudFront stops holding a copy. A mirror is written private, no-store and is never stored anywhere on the path. So every agent visit invokes the router and is recorded exactly once, and there is no cache anywhere that could hand an agent page to a person.
Every page request is a Lambda@Edge invocation, search crawlers included, and your origin takes every page request it would have taken without a CDN cache in front of it. For a human request the invocation is a few milliseconds of string matching with no network call, and the asset and framework carve-outs keep the bulk of a page's requests off the function entirely.
The ceiling is the regional Lambda@Edge concurrency quota, which is shared with every other Lambda you run in that region and defaults to 1,000 (often 10 on a new account); each instance serves ten requests a second, so the quota times ten is the page-request rate a region can take before CloudFront answers 503 on every page request there. The install reads the quota in all thirteen Lambda@Edge regions and prints it, and the new-distribution template enables a per-address WAF rate limit by default. A breach is a 503, not a silent fallback; the request cap is the one failure this design cannot degrade past.
The router keeps no copy of any mirror. Every diverted request fetches it from the NORG content service, which holds a per-location cache in front of the mirror store behind its own authentication check, so a repeat crawl of the same page is answered from that cache without a storage read. Because every fetch reaches that service, it is also where the visit is recorded: the router passes the user agent, the classification, what it is about to serve and the viewer's network context as one request header, and the service writes the event through its own deferred-work primitive after it has answered. Nothing about analytics runs in your account or waits in front of your visitors.
One event per agent visit, written by the NORG content service, from the details the router passes in one request header on the mirror fetch. When your origin request policy forwards CloudFront's viewer location, HTTP and TLS headers (the managed AllViewerExceptHostHeader does; the managed AllViewer does not), these are populated, and null otherwise: ip_country, ip_city, asn, http_protocol, tls_version. These two are always null, because CloudFront publishes no equivalent in request headers and inventing a value would be worse than an absent one: as_organization, colo. The event also records the classification, what the router served, and the user agent. When no mirror exists yet, it records the fallback the router declared, stripped or origin; this platform does not distinguish a strip that was discarded for being too thin. Passthrough events for humans are off unless you turn them on with x-norg-events-verbose, and on this platform they are sent best-effort. No query strings, no cookies, and none of the visitor's own request headers beyond the user agent. No IP either: the address is read inside the runtime for source verification and never leaves it.
Route exclusions
Most requests to your site should never reach our code. On CloudFront that is enforced by the distribution rather than by us, and the set that matters most on your site is one only you can name.
| Carve-out | Patterns | Why |
|---|---|---|
| Framework assets | /wp-content/*, /wp-includes/* — or /_next/*, /_vercel/* on Next.js | Chosen from the platform detected at install. These are the bulk of a page’s requests and none of them is a mirrorable document. |
| Static suffixes | 49 extensions: images, CSS, JS, fonts, media, archives | Catches assets that live outside the framework prefixes, so a stray /img/logo.png does not invoke the function either. |
| Dynamic routes | /api/*, /wp-json/*, /wp-admin/*, /wp-login.php, /cart, /cart/*, /checkout, /checkout/* | Paths that are transactional or authenticated rather than content. Served with caching disabled and the viewer’s Host corrected, so they behave exactly as they did before the install. |
On this platform a carve-out is a CloudFront cache behaviour with no function association. A request matching one never reaches our code at all, so it is not merely unmodified, it is untouched: nothing is classified, nothing is logged, and no invocation is billed. That is stronger than the in-code fast path other platforms have to rely on, and it is why exclusions are the cost control here as well as the safety one.
CloudFront matches the first behaviour that fits, so order decides outcomes: the carve-outs are created ahead of the default, and a narrower one ahead of a broader one. The install prints the full list before it creates anything, and it refuses rather than overwrite a behaviour you already have on the same path.
The defaults cover the two authenticated paths that are unambiguous on WordPress, /wp-admin/* and /wp-login.php, plus cart and checkout. Everything else behind a login is site-specific and has to be named by you: a customer portal, a member area, a booking history, a patient record, an internal dashboard.
Why we do not guess. Patterns like /account* and /login* were considered as defaults and deliberately left out, because they also match ordinary marketing pages such as /accounting and /login-help. A carve-out that silently un-mirrors a real page is the exact failure the whole design exists to prevent, so the defaults stay narrow and the site-specific ones are yours to add.
What is already safe without any exclusion. Nothing a person submits is ever mediated: only GET requests are considered, so sign-in, checkout and every form POST leave at the first check. The one POST the router handles at all is to an MCP path, and that is relayed to NORG, never to your origin. Content behind a login is never crawled or mirrored either, because NORG only ever sees what an anonymous public crawler sees. So an un-excluded account page is not a disclosure risk.
Why exclude it anyway. Three reasons, in order of how much they will matter to you. It removes those requests from the invocation bill entirely, which matters more now that every page request invokes the router. It keeps a signed-in visitor's page on the shortest possible path through the edge, with no classification at all. And it makes the boundary explicit in your own CloudFront configuration, which is a much easier thing to hand an auditor than an argument about what the code does.
Artifacts, verification and deployment
What you deploy, how to prove it is what we published, and what the platform will not let either of us do.
The two artifacts are plain JavaScript in the public deploy repository, each built from source in the same repository, and each recorded with its full SHA-256 in a committed DIGESTS.json next to them. Hash the files you are about to deploy and compare. The build refuses to publish a bundle that contains a NORG-internal secret name, and refuses one that requires anything the Lambda runtime does not provide.
There is no exception: the router does not use the AWS SDK. Its one AWS call, reading the site key from Secrets Manager, is a single HTTPS request it signs itself with the function role's own credentials, in a short file you can read. The scheduled heartbeat makes the same call and fails loudly, so a key it cannot read shows up in your CloudWatch metrics within the half-hour rather than degrading quietly.
CloudFormation for a new distribution, or a CLI that attaches to one you already have. The CLI dry-runs by default, prints the exact diff, refuses on a conflicting function association, and detaches with one flag. It replaces the default behaviour's cache policy with CachingDisabled and says so in the dry run, because after the install your HTML is no longer cached at the edge; it also prints the Lambda@Edge concurrency quota in every region. It writes no credential into your distribution. Artifacts are pinned by S3 object version, so a rollback is a stack update naming the previous version.
Propagation is five to fifteen minutes for a distribution update, and the kill switch is an origin custom header, so it inherits that timing. Revoking the site key on our side is slower, not faster: the router only learns of it at its next policy refresh, which is up to an hour away by default, and if the secret in your own account is what changed, the fifteen-minute secret cache applies on top. Use the kill switch to stop quickly; use revocation to stop permanently.
Every artifact and log group here is named us-east-1, which looks alarming if your visitors are not in Virginia. It is an administrative detail, not a routing one.
AWS requires the function resource to be created in us-east-1. That is the only thing that region governs. CloudFront then replicates the published version to its regional edge caches and runs it there, so a request from Sydney is classified in Sydney and a request from Frankfurt in Frankfurt, and the site key is read from the Secrets Manager replica in that same region. Nothing is routed via Virginia, and nothing crosses the Pacific to make a decision.
You can confirm this in your own account rather than take our word for it. The log group is named /aws/lambda/us-east-1.<function>, but it is created per region, and the entries appear in the region nearest the edge that served the request. On our own reference install, streams exist in Sydney, Oregon, Dublin and Tokyo; none of that traffic was executed in Virginia.
The practical consequence: classification adds a few milliseconds in the data centre the request had already reached, not a round trip to another continent.
Lambda@Edge supports no environment variables, no layers, no VPC access, no provisioned concurrency and no X-Ray, and functions must be attached as published versions rather than an alias. There is no way to keep work alive after the function returns, which is why the router sends no telemetry: the visit travels as a header on the mirror fetch and the NORG content service records it. Generated responses are capped, and exceeding the cap degrades to your origin. CloudFront resolves the origin's DNS before the function runs, so changing the origin domain inside the function triggers a second resolution.
No verified-bot signal, so source verification is CIDR-only against operator-published ranges. An operator that publishes no ranges is not diverted here, where Cloudflare would fall back to its own signal. That is a capability gap and the direction of the failure is the safe one: it under-serves agents rather than over-serving humans.
The installer credential, and its least-privilege policy
The execution roles read one secret. The credential NORG uses to build and attach the stack needs more, and every action below was discovered by a stack that failed without it. Attach this policy to that user or role; replace <account-id> with your account id.
Everything the install creates belongs to one CloudFormation stack named norg-edge- followed by the first eight hex characters of your site id, and the stack names its functions, roles, schedule rule and alarm under the same prefix. So the credential that runs install_on_cdn can be confined to norg-edge-* for stacks, Lambda functions, IAM roles, EventBridge rules and CloudWatch alarms: it cannot create, change or delete anything in your account that does not carry that name. This is not the execution role's permission described above; it is the credential that builds and attaches the stack, which the Lambda never holds.
Three things cannot be scoped that way, because AWS gives them ids rather than names: the distribution and policy calls in CloudFront, the log groups, and the quota reads. Those take *, and the actions listed for them are the narrowest set the stack uses.
Secrets Manager is the one statement that widens with the adapter version. The install creates the site-key secret and the stack replicates it, so the credential needs the secret's full lifecycle. Until adapter 0.6.1 CloudFormation names that secret SiteKeySecret-<random>, so the resource has to be secret:*, as shown below. From 0.6.1 it is named <stack-name>-site-key and the statement can be tightened to secret:norg-edge-*.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stacks",
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:DeleteStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources",
"cloudformation:ListStacks"
],
"Resource": "arn:aws:cloudformation:us-east-1:<account-id>:stack/norg-edge-*/*"
},
{
"Sid": "StackList",
"Effect": "Allow",
"Action": [
"cloudformation:ListStacks"
],
"Resource": "*"
},
{
"Sid": "Lambda",
"Effect": "Allow",
"Action": [
"lambda:CreateFunction",
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:PublishVersion",
"lambda:DeleteFunction",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:AddPermission",
"lambda:RemovePermission",
"lambda:EnableReplication",
"lambda:GetPolicy",
"lambda:TagResource",
"lambda:ListVersionsByFunction"
],
"Resource": "arn:aws:lambda:us-east-1:<account-id>:function:norg-edge-*"
},
{
"Sid": "LambdaQuotas",
"Effect": "Allow",
"Action": [
"lambda:GetAccountSettings",
"servicequotas:GetServiceQuota"
],
"Resource": "*"
},
{
"Sid": "Roles",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:GetRole",
"iam:PassRole",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:GetRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole"
],
"Resource": "arn:aws:iam::<account-id>:role/norg-edge-*"
},
{
"Sid": "ServiceLinkedRoles",
"Effect": "Allow",
"Action": [
"iam:CreateServiceLinkedRole",
"iam:GetRole"
],
"Resource": [
"arn:aws:iam::<account-id>:role/aws-service-role/replicator.lambda.amazonaws.com/AWSServiceRoleForLambdaReplicator",
"arn:aws:iam::<account-id>:role/aws-service-role/logger.cloudfront.amazonaws.com/AWSServiceRoleForCloudFrontLogger"
]
},
{
"Sid": "CloudFront",
"Effect": "Allow",
"Action": [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
"cloudfront:UpdateDistribution",
"cloudfront:ListDistributions",
"cloudfront:CreateOriginRequestPolicy",
"cloudfront:GetOriginRequestPolicy",
"cloudfront:DeleteOriginRequestPolicy",
"cloudfront:CreateCachePolicy",
"cloudfront:GetCachePolicy",
"cloudfront:DeleteCachePolicy",
"cloudfront:ListCachePolicies",
"cloudfront:ListOriginRequestPolicies"
],
"Resource": "*"
},
{
"Sid": "Secrets",
"Effect": "Allow",
"Action": [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteResourcePolicy",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:GetSecretValue",
"secretsmanager:PutResourcePolicy",
"secretsmanager:PutSecretValue",
"secretsmanager:RemoveRegionsFromReplication",
"secretsmanager:ReplicateSecretToRegions",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:UpdateSecret"
],
"Resource": "arn:aws:secretsmanager:us-east-1:<account-id>:secret:*"
},
{
"Sid": "Schedule",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:DeleteRule",
"events:DescribeRule",
"events:PutTargets",
"events:RemoveTargets"
],
"Resource": "arn:aws:events:us-east-1:<account-id>:rule/norg-edge-*"
},
{
"Sid": "Alarms",
"Effect": "Allow",
"Action": [
"cloudwatch:PutMetricAlarm",
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms"
],
"Resource": "arn:aws:cloudwatch:us-east-1:<account-id>:alarm:norg-edge-*"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DescribeLogGroups"
],
"Resource": "*"
},
{
"Sid": "ReadRouterLogs",
"Effect": "Allow",
"Action": [
"logs:FilterLogEvents",
"logs:GetLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "arn:aws:logs:*:<account-id>:log-group:/aws/lambda/us-east-1.norg-edge-*"
}
]
}| Action | Why it is there |
|---|---|
| cloudformation:DescribeStackResource | Singular, and needed as well as the plural. The attach step reads the site-key secret's ARN with it, because 0.6.0 publishes no stack output for that ARN. A denial here used to be read as "no secret" and wrote an empty ARN into the distribution; release 250 made it fail the install instead, but the permission is still required. |
| secretsmanager:ReplicateSecretToRegions, RemoveRegionsFromReplication | The stack replicates the secret to the other twelve Lambda@Edge regions on create and withdraws the replicas on delete. |
| secretsmanager:CreateSecret, TagResource, PutSecretValue, DescribeSecret, GetSecretValue, DeleteSecret | The secret's own lifecycle: created by the stack, tagged, given the key, read back once to confirm it, and removed with the stack. |
| lambda:EnableReplication | What Lambda@Edge itself needs: CloudFront copies the published version to its regional caches, and AWS models that as a permission on the function. The other Lambda actions are the create, publish and permission calls the stack makes. |
| cloudfront:Create/Get/Delete CachePolicy and OriginRequestPolicy, plus the two List calls | The stack creates one cache policy and one origin request policy. Their ARNs are ids assigned on creation, so they cannot be scoped to the stack name; nor can the distribution calls. |
| lambda:GetAccountSettings, servicequotas:GetServiceQuota | The install reads the Lambda@Edge concurrency quota in every region and prints it, so a breach is visible before it happens. |
| iam:PassRole | CloudFormation hands the two execution roles to Lambda on the credential's behalf. Scoped to norg-edge-*, it can pass no other role. |
| logs:FilterLogEvents, logs:GetLogEvents, logs:DescribeLogStreams (on /aws/lambda/us-east-1.norg-edge-*) | Read-only, and the one thing whose absence made the first customer's timeout undiagnosable: it lets NORG read the router's own Lambda@Edge logs to diagnose how the router answered an agent request. Scoped to the router's log groups and nothing else in your account; it reads logs, never writes or deletes them. The install preview probes it up front and warns if it is missing, so you grant it once rather than discover it after a failure. |
| iam:CreateServiceLinkedRole, iam:GetRole (on the two aws-service-role ARNs) | Lambda@Edge needs the service-linked roles AWSServiceRoleForLambdaReplicator (replicator.lambda.amazonaws.com) and AWSServiceRoleForCloudFrontLogger (logger.cloudfront.amazonaws.com) to exist in your account; the attach fails with "not authorized to create or assume a service linked role" if they do not and the credential cannot create them. The install preview calls iam:GetRole on both before any stack is built and warns when one is missing or unreadable, so this is granted up front rather than discovered as a rollback. |
The gate layout: one CloudFront Function, no Lambda
An alternative install that NORG offers alongside the Lambda@Edge router. It changes less in your account, needs a smaller credential, and has no secret to look after. Ask for it with layout="gate" on install_cloudfront; the default remains the Lambda layout described above.
| What changes | Detail |
|---|---|
| A second origin, norg-receptionist | NORG's edge-content receptionist, reached over HTTPS only (TLS 1.2). It carries two origin custom headers: X-Norg-Site-Id (your site id) and X-Norg-Mirror-Token, a per-site credential the receptionist checks before it serves anything. The token is written once at attach and rewritten on rotation; only its hash is stored by NORG. |
| One CloudFront Function on viewer-request | A ~2 KB function (runtime cloudfront-js-2.0) on the default behaviour. It classifies the visitor and, for an AI agent, points the request at the receptionist origin for that one request; everyone else continues to your origin untouched. An earlier NORG viewer classifier or NORG Lambda@Edge association on the behaviour is removed; anyone else’s function or Lambda makes the install refuse. |
| Cache policy: managed CachingDisabled | The default behaviour moves to CloudFront's managed CachingDisabled policy, exactly as the Lambda layout does. HTML is no longer cached at the edge and your origin answers every page request. This is what stops a mirror page served to an agent being cached and handed to the next person at the same URL: with a cache policy keyed on the URL alone, a minimum TTL above zero overrides the receptionist's no-store. Your previous cache policy is recorded and put back on uninstall. Asset behaviours are unaffected. |
| Origin request policy: yours if it forwards everything, else AllViewerExceptHostHeader | NORG reads your current origin request policy. If it already forwards every viewer header (allViewer, allViewerAndWhitelistCloudFront, or allExcept with no X-Norg-* header excluded) and the full query string, it is kept: the gate headers reach the receptionist and your Host handling is untouched. Otherwise the behaviour moves to the AWS-managed AllViewerExceptHostHeader policy (id b689b0a8-53d0-40ab-baf2-68738e2966ac): every viewer header except Host, plus the full query string. Legacy forwarding settings, which cannot coexist with a policy, always move to the managed pair. Whatever is replaced is recorded and put back on uninstall. |
| MCP carve-out behaviours removed | The Lambda-era installer added cache behaviours for */mcp, */sse and /.well-known/mcp.json that sent those paths straight to your origin. The gate treats MCP descriptors as NORG surfaces itself, and those behaviours broke on origins that reject a forwarded Host, so a gate install removes exactly those three. Other behaviours (API, admin, cart, static assets) are left as they are. |
No Lambda@Edge function, so no IAM execution role, no service-linked roles and no regional replicas to wait for on delete. No Secrets Manager secret: the site key never leaves NORG, and the only credential on your distribution is the mirror token on the receptionist origin. No EventBridge schedule and no heartbeat function: the gate stack is one CloudFront Function plus its CloudWatch alarms. Because nothing heartbeats, the site activates on the receptionist's first gate-seen stamp: when it serves the first agent request through the mirror token, it tells NORG, and that flips the site active exactly as a heartbeat would.
The credential that builds and attaches a gate stack needs none of the Lambda, IAM role, Secrets Manager, EventBridge or log-group statements shown in the Lambda policy above. It needs the stack itself, the function (scoped to norg-edge-*), the distribution read and update, the two read-only policy reads the preview uses, and the alarms. Replace <account-id>with your account id.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stacks",
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:UpdateStack",
"cloudformation:DeleteStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources"
],
"Resource": "arn:aws:cloudformation:us-east-1:<account-id>:stack/norg-edge-*/*"
},
{
"Sid": "StackList",
"Effect": "Allow",
"Action": [
"cloudformation:ListStacks"
],
"Resource": "*"
},
{
"Sid": "GateFunction",
"Effect": "Allow",
"Action": [
"cloudfront:CreateFunction",
"cloudfront:UpdateFunction",
"cloudfront:PublishFunction",
"cloudfront:DeleteFunction",
"cloudfront:DescribeFunction",
"cloudfront:GetFunction",
"cloudfront:TestFunction",
"cloudfront:ListTagsForResource",
"cloudfront:TagResource",
"cloudfront:UntagResource"
],
"Resource": "arn:aws:cloudfront::<account-id>:function/norg-edge-*"
},
{
"Sid": "Distribution",
"Effect": "Allow",
"Action": [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
"cloudfront:UpdateDistribution",
"cloudfront:ListDistributions",
"cloudfront:GetCachePolicy",
"cloudfront:GetOriginRequestPolicy"
],
"Resource": "*"
},
{
"Sid": "Alarms",
"Effect": "Allow",
"Action": [
"cloudwatch:PutMetricAlarm",
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms"
],
"Resource": "arn:aws:cloudwatch:us-east-1:<account-id>:alarm:norg-edge-*"
}
]
}Installer access: one document, asked for once
Both install layouts, the optional access logs and later tightening of the grant are covered by one policy, so your AWS admin is asked exactly once. Run the template or attach the JSON; either way takes about five minutes.
It can create, update, delete and re-create NORG's CloudFormation stack; manage CloudFront Functions and Lambda@Edge functions named norg-edge-*; read and update your distribution's configuration; create two CloudWatch alarms; write CloudFront access logs to a bucket named norg-edge-logs-* in your account (the files stay with you; NORG reads them for timing reports); and read the installer user's own policies. Created with the template, the user also carries a permissions boundary equal to this grant, and may rewrite its own inline policy within it: that is how NORG scales the grant back later without asking you again, and why it can never widen it. It cannot read your data or your other secrets, touch other IAM users or roles, or change anything not named norg-edge-*.
Download the template below. In the AWS console open CloudFormation, choose Create stack, upload the file, name the stack norg-edge-installer, tick the acknowledgement about IAM resources with custom names, and create it. It makes one IAM user named norg-edge-installer with the policy attached and set as its permissions boundary, creates an access key, and stores the key in Secrets Manager as norg-edge-installer/access-key. Nothing else in the account is touched.
Create an IAM user, attach the JSON below as an inline policy named NorgEdgeInstallerFull with account-id replaced by your account id, and create an access key for it. This document lets NORG read, not rewrite, the user's own policy, so narrowing the grant later needs your admin again; the template avoids that.
Send NORG the access key id and secret through a secure channel, never by email, together with the id of each CloudFront distribution NORG should install on. NORG stores the key encrypted, bound to your workspace, and uses it only for the actions in this policy.
Download norg-edge-installer-user.json and upload it to CloudFormation. It has no placeholders: the account id is resolved when the stack runs.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stacks",
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:UpdateStack",
"cloudformation:DeleteStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources"
],
"Resource": "arn:aws:cloudformation:us-east-1:<account-id>:stack/norg-edge-*/*"
},
{
"Sid": "StackList",
"Effect": "Allow",
"Action": [
"cloudformation:ListStacks"
],
"Resource": "*"
},
{
"Sid": "GateFunction",
"Effect": "Allow",
"Action": [
"cloudfront:CreateFunction",
"cloudfront:UpdateFunction",
"cloudfront:PublishFunction",
"cloudfront:DeleteFunction",
"cloudfront:DescribeFunction",
"cloudfront:GetFunction",
"cloudfront:TestFunction",
"cloudfront:ListTagsForResource",
"cloudfront:TagResource",
"cloudfront:UntagResource"
],
"Resource": "arn:aws:cloudfront::<account-id>:function/norg-edge-*"
},
{
"Sid": "Distribution",
"Effect": "Allow",
"Action": [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
"cloudfront:UpdateDistribution",
"cloudfront:ListDistributions",
"cloudfront:GetCachePolicy",
"cloudfront:GetOriginRequestPolicy"
],
"Resource": "*"
},
{
"Sid": "Alarms",
"Effect": "Allow",
"Action": [
"cloudwatch:PutMetricAlarm",
"cloudwatch:DeleteAlarms",
"cloudwatch:DescribeAlarms"
],
"Resource": "arn:aws:cloudwatch:us-east-1:<account-id>:alarm:norg-edge-*"
},
{
"Sid": "Lambda",
"Effect": "Allow",
"Action": [
"lambda:CreateFunction",
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:PublishVersion",
"lambda:DeleteFunction",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:AddPermission",
"lambda:RemovePermission",
"lambda:EnableReplication",
"lambda:GetPolicy",
"lambda:TagResource",
"lambda:ListVersionsByFunction"
],
"Resource": "arn:aws:lambda:us-east-1:<account-id>:function:norg-edge-*"
},
{
"Sid": "LambdaQuotas",
"Effect": "Allow",
"Action": [
"lambda:GetAccountSettings",
"servicequotas:GetServiceQuota"
],
"Resource": "*"
},
{
"Sid": "Roles",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:GetRole",
"iam:PassRole",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:GetRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole"
],
"Resource": "arn:aws:iam::<account-id>:role/norg-edge-*"
},
{
"Sid": "ServiceLinkedRoles",
"Effect": "Allow",
"Action": [
"iam:CreateServiceLinkedRole",
"iam:GetRole"
],
"Resource": [
"arn:aws:iam::<account-id>:role/aws-service-role/replicator.lambda.amazonaws.com/AWSServiceRoleForLambdaReplicator",
"arn:aws:iam::<account-id>:role/aws-service-role/logger.cloudfront.amazonaws.com/AWSServiceRoleForCloudFrontLogger"
]
},
{
"Sid": "Secrets",
"Effect": "Allow",
"Action": [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteResourcePolicy",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:GetSecretValue",
"secretsmanager:PutResourcePolicy",
"secretsmanager:PutSecretValue",
"secretsmanager:RemoveRegionsFromReplication",
"secretsmanager:ReplicateSecretToRegions",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:UpdateSecret"
],
"Resource": "arn:aws:secretsmanager:us-east-1:<account-id>:secret:norg-edge-*"
},
{
"Sid": "Schedule",
"Effect": "Allow",
"Action": [
"events:PutRule",
"events:DeleteRule",
"events:DescribeRule",
"events:PutTargets",
"events:RemoveTargets"
],
"Resource": "arn:aws:events:us-east-1:<account-id>:rule/norg-edge-*"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:PutRetentionPolicy",
"logs:DescribeLogGroups"
],
"Resource": "*"
},
{
"Sid": "ReadRouterLogs",
"Effect": "Allow",
"Action": [
"logs:FilterLogEvents",
"logs:GetLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "arn:aws:logs:*:<account-id>:log-group:/aws/lambda/us-east-1.norg-edge-*"
},
{
"Sid": "CloudFrontPolicies",
"Effect": "Allow",
"Action": [
"cloudfront:CreateOriginRequestPolicy",
"cloudfront:DeleteOriginRequestPolicy",
"cloudfront:CreateCachePolicy",
"cloudfront:DeleteCachePolicy",
"cloudfront:ListCachePolicies",
"cloudfront:ListOriginRequestPolicies"
],
"Resource": "*"
},
{
"Sid": "AccessLogsBucket",
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:DeleteBucket",
"s3:GetBucketAcl",
"s3:PutBucketAcl",
"s3:GetBucketOwnershipControls",
"s3:PutBucketOwnershipControls",
"s3:PutBucketPublicAccessBlock",
"s3:PutLifecycleConfiguration",
"s3:ListBucket",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::norg-edge-logs-*",
"arn:aws:s3:::norg-edge-logs-*/*"
]
},
{
"Sid": "SelfManagePolicies",
"Effect": "Allow",
"Action": [
"iam:GetUser",
"iam:GetUserPolicy",
"iam:ListUserPolicies",
"iam:ListAttachedUserPolicies"
],
"Resource": "arn:aws:iam::<account-id>:user/${aws:username}"
}
]
}Write to solutions@norg.ai with your distribution id and the section this is about. We will send the source for the version you are being asked to deploy, and your site's probe token.
