rename EVENT_INGEST_KEY -> PUBLISHABLE_KEY
Hanzo CI/CD / cicd (push) Canceled after 0s
CI/CD / cicd (push) Canceled after 7m59s

The value is a pk- prefixed PUBLISHABLE key (the Stripe vocabulary, and what
the build-time gate already tests for). EVENT_INGEST_KEY hid that.

One substitution covers all three spellings, since the framework prefixes wrap
the same token: EVENT_INGEST_KEY -> PUBLISHABLE_KEY, NEXT_PUBLIC_* and VITE_*
follow. KMS already carries deploy/PUBLISHABLE_KEY (env prod) with the same
value, written and read back first, so no build can reach a name that does not
exist yet.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-08-05 16:24:50 -07:00
co-authored by hanzo-dev
parent 3c6ad37130
commit f44d441d88
10 changed files with 39 additions and 39 deletions
+2 -2
View File
@@ -6,8 +6,8 @@ name: deploy
#
# The reason is one capability, not tidiness. ui.hanzo.ai is a static export, so
# its ingest key has to be BAKED IN at build; the key lives in KMS as
# `deploy/EVENT_INGEST_KEY`, and hanzoai/ci is the only lane that reads it and
# passes it as `--build-arg EVENT_INGEST_KEY=…`. The `docker build -t "$image" .`
# `deploy/PUBLISHABLE_KEY`, and hanzoai/ci is the only lane that reads it and
# passes it as `--build-arg PUBLISHABLE_KEY=…`. The `docker build -t "$image" .`
# below cannot — there is no KMS hop in it and nowhere to put the value — so any
# image it produced would ship a client with no key at all, and cloud answers
# `401 ingest_key_required` for an unattributed write. With the Dockerfile now
+12 -12
View File
@@ -16,12 +16,12 @@ ENV NEXT_TELEMETRY_DISABLED=1
# is safe in a public bundle; the deployment that builds decides which org the
# site reports as.
#
# ONE name, end to end: KMS holds `deploy/EVENT_INGEST_KEY`, hanzo.yml declares it
# ONE name, end to end: KMS holds `deploy/PUBLISHABLE_KEY`, hanzo.yml declares it
# as this image's build_secret, and the KMS name IS the build-arg name. NEXT_PUBLIC_
# is added HERE because that prefix is what makes Next inline it — the app reads
# process.env.NEXT_PUBLIC_EVENT_INGEST_KEY.
# process.env.NEXT_PUBLIC_PUBLISHABLE_KEY.
#
# Do NOT re-declare `ARG NEXT_PUBLIC_EVENT_INGEST_KEY` after the ENV below. A later
# Do NOT re-declare `ARG NEXT_PUBLIC_PUBLISHABLE_KEY` after the ENV below. A later
# ARG of the same name shadows the ENV with its own (empty) default, and the build
# stays green while the bundle ships blank — which is exactly how hanzo.chat 1.0.58
# shipped a keyless site from a fully green run.
@@ -39,12 +39,12 @@ ENV NEXT_TELEMETRY_DISABLED=1
# so requiring the current `pk-` shape refuses the stale one outright.
#
# Neither failure is visible from outside the artifact, so refuse the artifact.
ARG EVENT_INGEST_KEY
ENV NEXT_PUBLIC_EVENT_INGEST_KEY=$EVENT_INGEST_KEY
RUN case "$EVENT_INGEST_KEY" in \
ARG PUBLISHABLE_KEY
ENV NEXT_PUBLIC_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
RUN case "$PUBLISHABLE_KEY" in \
pk-*) : ;; \
'') echo "EVENT_INGEST_KEY is empty - pass --build-arg EVENT_INGEST_KEY=<pk-...> (KMS deploy/EVENT_INGEST_KEY, env prod)" >&2; exit 1 ;; \
*) echo "EVENT_INGEST_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
'') echo "PUBLISHABLE_KEY is empty - pass --build-arg PUBLISHABLE_KEY=<pk-...> (KMS deploy/PUBLISHABLE_KEY, env prod)" >&2; exit 1 ;; \
*) echo "PUBLISHABLE_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
esac
# 300+ prerendered pages; the default heap is not enough.
ENV NODE_OPTIONS=--max-old-space-size=8192
@@ -71,16 +71,16 @@ RUN cd pkgs/event && pnpm build
#
# ...and then PROVES the key reached the client bundle. The gate above proves a
# key was PASSED; only this proves it was INLINED. Those are different failures:
# a rename on either side of `process.env.NEXT_PUBLIC_EVENT_INGEST_KEY` leaves the
# a rename on either side of `process.env.NEXT_PUBLIC_PUBLISHABLE_KEY` leaves the
# build-arg intact and the bundle keyless, and a static export cannot report that
# at runtime because there is no runtime.
#
# `&&`, never `;` — a `;` chain returns the LAST command's status, so a failed
# build followed by a passing grep exits 0 and the image is published.
RUN cd app && pnpm build && \
if [ -z "${NEXT_PUBLIC_EVENT_INGEST_KEY}" ]; then \
echo "ERROR: NEXT_PUBLIC_EVENT_INGEST_KEY is empty after a successful build." >&2; exit 1; \
elif grep -rqF "${NEXT_PUBLIC_EVENT_INGEST_KEY}" out; then \
if [ -z "${NEXT_PUBLIC_PUBLISHABLE_KEY}" ]; then \
echo "ERROR: NEXT_PUBLIC_PUBLISHABLE_KEY is empty after a successful build." >&2; exit 1; \
elif grep -rqF "${NEXT_PUBLIC_PUBLISHABLE_KEY}" out; then \
echo "Build OK - ingest key inlined into app/out, verified"; \
else \
echo "ERROR: key supplied but NOT present in app/out - ui would ship unattributed" >&2; exit 1; \
+1 -1
View File
@@ -474,7 +474,7 @@ whole setup, and every click / change / submit / route change inside the tree
reaches `POST /v1/event` named by the component it happened on:
```tsx
<Hanzo analytics={{ product: 'console', ingestKey: process.env.NEXT_PUBLIC_EVENT_INGEST_KEY }}>
<Hanzo analytics={{ product: 'console', ingestKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY }}>
```
Four packages, one of each concern, no duplication:
+4 -4
View File
@@ -13,9 +13,9 @@ import { createAnalytics } from "@hanzo/event"
// reports as the org that deployed it; without one cloud answers
// `401 ingest_key_required` and every logged-out pageview is dropped in silence.
//
// NEXT_PUBLIC_EVENT_INGEST_KEY is the ONE name, and it is the name the fleet
// already carries end to end: KMS holds `deploy/EVENT_INGEST_KEY`, the Dockerfile
// takes it as the EVENT_INGEST_KEY build-arg and re-exports it with the
// NEXT_PUBLIC_PUBLISHABLE_KEY is the ONE name, and it is the name the fleet
// already carries end to end: KMS holds `deploy/PUBLISHABLE_KEY`, the Dockerfile
// takes it as the PUBLISHABLE_KEY build-arg and re-exports it with the
// NEXT_PUBLIC_ prefix that makes Next inline it.
//
// This file used to read NEXT_PUBLIC_HANZO_INGEST_KEY — a spelling nothing in KMS
@@ -31,5 +31,5 @@ import { createAnalytics } from "@hanzo/event"
// resolves to undefined in a static export.
export const analytics = createAnalytics({
product: "site",
ingestKey: process.env.NEXT_PUBLIC_EVENT_INGEST_KEY,
ingestKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
})
+1 -1
View File
@@ -75,7 +75,7 @@ images:
dockerfile: Dockerfile
repo: ghcr.io/hanzoai/ui
platforms: [linux/amd64]
build_secrets: [EVENT_INGEST_KEY]
build_secrets: [PUBLISHABLE_KEY]
# No `deploy:` — ON PURPOSE. hanzoai/universe names what is live, in
# charts/app/values/hanzo/ui.yaml, and the in-cluster reconcile restores it — so a
+3 -3
View File
@@ -119,7 +119,7 @@ registerField('rating', { Display: MyStars, Input: MyStarPicker })
```tsx
import { Hanzo } from '@hanzo/ui'
<Hanzo analytics={{ product: 'console', ingestKey: process.env.NEXT_PUBLIC_EVENT_INGEST_KEY }}>
<Hanzo analytics={{ product: 'console', ingestKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY }}>
<App />
</Hanzo>
```
@@ -183,8 +183,8 @@ in **both** directions, because that is what explicit means. Beyond that:
`ingestKey` is a publishable `pk-…` — write-only, safe in a bundle, minted per
org with `POST /v1/keys {"type":"publishable"}`. Omit it and the client reads
`NEXT_PUBLIC_EVENT_INGEST_KEY` from the build env, which is the spelling the
fleet already carries end to end (KMS holds `deploy/EVENT_INGEST_KEY`; the
`NEXT_PUBLIC_PUBLISHABLE_KEY` from the build env, which is the spelling the
fleet already carries end to end (KMS holds `deploy/PUBLISHABLE_KEY`; the
Dockerfile takes it as a build-arg and re-exports it with the `NEXT_PUBLIC_`
prefix Next needs to inline it). A surface with no key reports only for whoever
is signed in and silently drops every logged-out visitor — the door refuses an
+1 -1
View File
@@ -239,7 +239,7 @@ Verified against the trees at the time of writing. ✅ = emitting, ❌ = not emi
no org today; chat funnels are person-scoped only.
5. **Logged-out reach depends on a publishable key.** Anonymous events need
`ingestKey` (`pk_…`, write-only) or they fail closed at the door.
`NEXT_PUBLIC_HANZO_INGEST_KEY` (hanzo.ai), `NEXT_PUBLIC_EVENT_INGEST_KEY`
`NEXT_PUBLIC_HANZO_INGEST_KEY` (hanzo.ai), `NEXT_PUBLIC_PUBLISHABLE_KEY`
(hanzo.app), `VITE_HANZO_INGEST_KEY` (hanzo.chat) are read but must be
provisioned per org via `POST /v1/ingest/keys`. Config, not code.
+8 -8
View File
@@ -360,26 +360,26 @@ describe('Analytics capture', () => {
// The failure this closes is silent: a surface with no key attributes nothing
// for a logged-out visitor, the door refuses the write, and the page shows no
// sign of it. The key must resolve from the env exactly as the DSN does.
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-from-env'
process.env.NEXT_PUBLIC_PUBLISHABLE_KEY = 'pk-live-from-env'
try {
const a = mk() // no key in config
a.capture('x')
a.flush(true)
expect(tx.sent[0].ingestKey).toBe('pk-live-from-env')
} finally {
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
delete process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
}
})
it('prefers an explicit ingest key over the build env', () => {
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-from-env'
process.env.NEXT_PUBLIC_PUBLISHABLE_KEY = 'pk-live-from-env'
try {
const a = mk({ ingestKey: 'pk-live-explicit' })
a.capture('x')
a.flush(true)
expect(tx.sent[0].ingestKey).toBe('pk-live-explicit')
} finally {
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
delete process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
}
})
@@ -394,7 +394,7 @@ describe('Analytics capture', () => {
// The leak this closes: one console bundle is served to several brands, and a
// pk- names ONE org. If an env-sourced key displaced the bearer, every
// signed-in user's events would re-file under whichever org minted the key.
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-one-org'
process.env.NEXT_PUBLIC_PUBLISHABLE_KEY = 'pk-live-one-org'
try {
const a = mk({ getToken: () => 'jwt-of-a-real-person' })
a.capture('x')
@@ -402,12 +402,12 @@ describe('Analytics capture', () => {
expect(tx.sent[0].token).toBe('jwt-of-a-real-person')
expect(tx.sent[0].ingestKey).toBeUndefined()
} finally {
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
delete process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
}
})
it('an anonymous visitor still rides the key', () => {
process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-one-org'
process.env.NEXT_PUBLIC_PUBLISHABLE_KEY = 'pk-live-one-org'
try {
const a = mk({ getToken: () => undefined }) // logged out
a.capture('x')
@@ -415,7 +415,7 @@ describe('Analytics capture', () => {
expect(tx.sent[0].ingestKey).toBe('pk-live-one-org')
expect(tx.sent[0].token).toBeUndefined()
} finally {
delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
delete process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
}
})
+4 -4
View File
@@ -241,9 +241,9 @@ export class Analytics {
// The publishable key resolves the SAME way the DSN below does: an explicit
// config wins, else the inlined build-time env.
//
// NEXT_PUBLIC_EVENT_INGEST_KEY is that env, and it is the name the fleet
// ALREADY carries end to end — KMS holds deploy/EVENT_INGEST_KEY, each
// Dockerfile takes it as the EVENT_INGEST_KEY build-arg and re-exports it
// NEXT_PUBLIC_PUBLISHABLE_KEY is that env, and it is the name the fleet
// ALREADY carries end to end — KMS holds deploy/PUBLISHABLE_KEY, each
// Dockerfile takes it as the PUBLISHABLE_KEY build-arg and re-exports it
// with the NEXT_PUBLIC_ prefix Next needs to inline it. Reading anything
// else here would add a fourth spelling of one value.
//
@@ -253,7 +253,7 @@ export class Analytics {
// unattributed write is refused (401 ingest_key_required), which is silent
// in the page and invisible until you read the warehouse and find the host
// missing entirely.
ingestKey: config.ingestKey ?? readEnv('NEXT_PUBLIC_EVENT_INGEST_KEY'),
ingestKey: config.ingestKey ?? readEnv('NEXT_PUBLIC_PUBLISHABLE_KEY'),
}
this.transport = config.transport ?? new DefaultTransport()
// Error plane, most specific source first: an explicit DSN wins, then the
+3 -3
View File
@@ -120,11 +120,11 @@ export interface AnalyticsConfig {
* a reading principal — so it is safe to ship in a bundle. Mint one per org with
* POST /v1/keys {"type":"publishable"}.
*
* Omit it and the client reads NEXT_PUBLIC_EVENT_INGEST_KEY from the inlined
* Omit it and the client reads NEXT_PUBLIC_PUBLISHABLE_KEY from the inlined
* build env, the same way `dsn` falls back — so a surface declares BOTH planes
* in its build and neither needs code to switch on. That is the ONE spelling
* the fleet already carries: KMS holds deploy/EVENT_INGEST_KEY, and each
* Dockerfile takes EVENT_INGEST_KEY as a build-arg and re-exports it with the
* the fleet already carries: KMS holds deploy/PUBLISHABLE_KEY, and each
* Dockerfile takes PUBLISHABLE_KEY as a build-arg and re-exports it with the
* NEXT_PUBLIC_ prefix that makes Next inline it.
*
* A surface with no key at all still reports for whoever is SIGNED IN (the