feat/enphase
needs attentionviewing older commit9704c80 · fullPR #282reviewed 2026-07-09 23:52 UTC8H · 14M · 9L · 3I- Purpose
- Port the Enphase 2-device legacy integration into the v2 metrics engine — replacing the secret write-back OAuth pattern with a DDB token store, and achieving billing-grade 15m native parity with the legacy DDB source.
- Goal
- Enphase Enlighten v4 integration — metrics worker (15m + 1d), single-use-refresh-token rotation via DDB (no write-back), CDK stacks, live-validated with exact parity
- Sub-goals
- SG-1: Probe + worker + token-rotation design + 47 unit tests
- SG-2: CDK (AuthCache + Lambda stacks) + coordinator grant
- SG-3: Preview data plane + sentinel grant-protection
- SG-4: Live SFN validation + Tinybird + exact legacy parity
- SG-N: Framework learnings fold-back (learnings #27-#32)
- SG-6: Rebase onto feat/int-base (multi-gran engine)
- SG-7: Multi-granularity worker (15m + 1d, 1M graceful skip, 53 tests)
- What
- New Enphase metrics worker (15m/1d, OAuth rotation via DDB), CDK stacks (auth-cache + lambda), 11 integration manifest stubs for future brand branches (abb, apsystems, fronius, hoymiles, huawei-portal, powerradar, shelly-cloud, sma, solark, solis, victron), migration scripts for legacy registry/secrets, enhancements to site-metrics transforms/compute for multi-gran support
- Why
- Legacy Enphase integration used secret write-back for OAuth rotation (brittle, IAM-write required). New design retires write-back: secret is read-only bootstrap, live token pair lives in DDB with CAS-protected rotation chain. Batch 3 port sibling of #280 (Victron), stacked on #268 (multi-gran engine).
- Areas
- services/metrics+3130−14scripts/metrics+1719−0packages/integration-manifests+1083−4infra/cdk+513−1domains/metrics+591−34packages/database+257−14domains/cross-domain+83−8packages/api+8−2infra/tinybird+11−0domains/core+16−1
- Blast
- 82 files, +7862/-213 across the enphase integration, CDK stacks, 11 manifest stubs, domains/metrics transforms, and scripts/metrics migration helpers
Findings · 21
correctness6
isInvalidGrantBody regex /refresh[ _]token/i too broad — false-positive dead-grant on benign 4xx bodies
services/metrics/integrations/enphase/src/engine/enlighten-client.ts:46
Matches any 4xx body mentioning 'refresh_token' as a field name or grant type — not only terminal invalid-grant responses. A mislabelled transient body triggers the CS re-authorize runbook.
rotationSpent=true after concurrent-rotation blocks valid 401-retry on adopted token
services/metrics/integrations/enphase/src/handlers/metrics.lambda.ts:195
Adopted token could already be stale; guard prevents a second rotation attempt, surfacing misleading UpstreamAuthFailed instead of attempting rotation.
1d MAX_WINDOW_DAYS=366 silently skips >366d backfill windows
services/metrics/integrations/enphase/src/handlers/metrics.lambda.ts:92
Lifetime endpoint serves all history (442d proven) in one call. The 366d cap applies 15m telemetry reasoning to an endpoint that needs no chunking.
releaseLock unconditional — can stomp concurrent holder's lock on failure paths
services/metrics/integrations/enphase/src/lib/token-store.ts:85
No ConditionExpression — a crashed rotation can zero out a newly-acquired lock, opening a window for simultaneous rotation and potential double-spend.
casWritePair does not verify lockUntil — stale writer can clobber in-flight lock holder
services/metrics/integrations/enphase/src/lib/token-store.ts:117
CAS guards on exchangedRefreshToken but not lock ownership; stale writer can succeed and zero lockUntil, breaking the new holder's rotation.
projectLifetime wh/24 not calendar-aware — off by ~4% on DST transition days
services/metrics/integrations/enphase/src/translation/points-to-batu.ts:306
Spring-forward (23h) over-estimates by ~4.35%, fall-back (25h) under-estimates by ~4.17%. billing-grade data.
security5
refresh_token exposed as query param in OAuth fallback URL — log/proxy leak risk
services/metrics/integrations/enphase/src/engine/enlighten-client.ts:68
Second-attempt fallback builds URL with ?refresh_token=... . AWS X-Ray, proxy logs, or Node error messages can capture the full URL. Single-use token in URL is a higher-leak channel than POST body.
IAM glob enphase/* covers portal human-login secrets
infra/cdk/src/stacks/services/metrics/integrations/enphase/iam.ts:45
Lambda can read portal credential secrets. Narrow to oid=* pattern.
secretConfigArn not validated — arbitrary-ARN GetSecretValue injection risk
services/metrics/integrations/enphase/src/handlers/metrics.lambda.ts:184
ARN comes from Invocation payload with no whitelist check. Should validate against expected enphase ARN prefix.
DDB token store has no customer-managed KMS encryption
infra/cdk/src/stacks/services/metrics/integrations/enphase/auth-cache.stack.ts:70
Table tagged confidential, holds live OAuth tokens, RETAIN in prod. AWS-owned key precludes KMS decrypt audit trail.
casWritePair DDB exception leaves lock stuck for 90s — no finally-releaseLock
services/metrics/integrations/enphase/src/lib/token-manager.ts:212
Transient DDB errors propagate without releasing the lock. Add try/finally around casWritePair.
conventions4
shelly-cloud credentialFields (user/password) contradict JSDoc (api_key/access_token)
packages/integration-manifests/src/manifests/shelly-cloud.ts:21
One of them is wrong. Brand branch will fail D2 secret copy or runtime reads if fields don't match the real secret payload.
5m declared for unproven vendors — MetricSource.granularity is immutable once provisioned
packages/integration-manifests/src/manifests/apsystems.ts:18
fronius, huawei-portal, solark, solis also affected. A later probe finding no 5m support requires deactivate+recreate of sources.
DDB errors throw rather than returning Result<T,E> — FCIS violation
services/metrics/integrations/enphase/src/lib/token-store.ts:80
tryAcquireLock and casWritePair re-throw DDB errors; callers are not in try/catch. Inconsistent with the Result pattern.
SMA manifest missing 1d fallback — all other integrations with 5m declare 1d
packages/integration-manifests/src/manifests/sma.ts:47
Legacy SMA lambda had daily totals. Omitting 1d prevents daily-grain MetricSource creation.
tests4
DST fall-back (clock set back) not tested for localMidnightUtcMs
services/metrics/integrations/enphase/src/__tests__/points-to-batu.test.ts:280
Spring-forward covered, fall-back creates ambiguous local midnight. Two-pass algorithm behavior on fall-back is undocumented and untested.
Partial-serve guard not tested for grid channels (nested intervals path)
services/metrics/integrations/enphase/src/__tests__/metrics-handler.test.ts:427
Guard for grid uses .intervals.flat().at(-1).end_at — different from meter path. Regression in the nested-flat branch would go undetected.
CAS-loss with no replacement pair (postRow.accessToken absent) not tested
services/metrics/integrations/enphase/src/__tests__/token-manager.test.ts:281
Existing CAS-loss test plants a winner row WITH an accessToken. The failure branch is untested.
Post-lock concurrent-winner adoption in rotateAsClaimHolder not tested as distinct scenario
services/metrics/integrations/enphase/src/__tests__/token-manager.test.ts:179
Existing test covers PRE-lock re-read adoption. Post-lock adopt path is untested.
improvement2
Timezone helpers copied verbatim from victron — extract to shared lib
services/metrics/integrations/enphase/src/translation/points-to-batu.ts:63
Will proliferate to every new brand branch. Extract to services/metrics/integrations/_shared/.
No-op .map(inner => inner) in mergeTelemetryChunks — identity map
services/metrics/integrations/enphase/src/handlers/metrics.lambda.ts:421
gridChunks.flatMap(c => c.intervals ?? []) is equivalent and clearer.