Go replacement for DysonNetwork.Ring — the Solar Network's realtime service: notifications, push delivery (FCM / APNs / UnifiedPush / SOP), email sending plans and delivery observability. A strict 1:1 behavioral port, following the house pattern established by the Stargate port of Padlock/Passport.
cmd/metoer/ entrypoint (HTTP :8080 + gRPC :9090, TLS optional)
internal/config TOML config + METOER_* env overrides
internal/db, migrate pgx pool + embedded SQL migrations (non-destructive)
internal/store Postgres access layer (mirrors EF Core queries incl.
the global soft-delete filter)
internal/model entity + wire models (snake_case JSON, nulls included)
internal/queue pusher_queue JetStream producer + durable consumer
internal/push PushService port: senders, SOP streams, replay,
websocket fan-out, invalid-token flush buffer
internal/email SMTP sender + email sending-plan state machine
internal/observability delivery records (never break the delivery path)
internal/grpcclient outbound clients (stargate: account/auth/permission/
profile/action-log; blade: websocket)
internal/grpcserver DyRingService + capabilities + reflection + health
internal/httpserver gin + /api controllers + swagger manifest
internal/middleware DysonTokenAuthHandler + RemotePermissionMiddleware ports
internal/scheduler the four Quartz jobs (UTC schedules)
internal/events websocket_push publisher + filesystem listener
Requires Postgres (dyson_ring — the same live database the C# fleet
uses), Redis and NATS with JetStream.
make run # CONFIG_PATH=config.example.tomlCONFIG_PATH selects the TOML file; METOER_* env vars override individual
keys (METOER_DATABASE__DSN, METOER_SERVICES_STARGATE__GRPC,
METOER_NATS_TARGET, …). Push keys (Keys/Solian.json, Keys/Solian.p8)
are mounted at deploy time, never baked into the image.
- API JSON: snake_case with nulls INCLUDED (Ring's
AddJsonOptionssets noDefaultIgnoreCondition) — unlike Stargate's omit-nulls convention, do not addomitemptyto API-facing fields. Enum values are ints; instants are RFC3339 UTC seconds.metadictionary keys are snake-cased on outbound responses (STJDictionaryKeyPolicy). - Queue wire format (
pusher_queue, JetStream): the envelope is snake_case with nulls included; thedatapayload is PascalCase with NodaTime instants serialized as{}(STJ default options — verified empirically). The C# consumer round-trips{}to epoch; Go does the same, preserving real timestamps on the DB-write path (notifications are saved before enqueue). - NATS: stream
pusher_queue(subjectpusher_queue), durable consumerpusher_workerswith deliver grouppusher_workers, AckPolicy Explicit, MaxDeliver 5, DeliverPolicy All on first creation. Core subjectwebsocket_pushcarries the{namespace,target,ids,excluded_device_ids, packet}envelope (packet = base64 protojsonDyWebSocketPacketwithnotifications.new). JetStreamaccount_events/accounts.last_active(PascalCaseLastActiveEvent) is published by the auth middleware (throttled 1m per account). - Redis keys (shared with the C# fleet via the
dyson:prefix):ring:sop:replay:*(+ lock),auth:session:*,auth:profile:*,auth:last_seen_touch:*. - Auth runs over gRPC to Stargate (
DyAuthService.Authenticate); permissions overDyPermissionService(Stargate), with the OAuth scope gate and superuser bypass. With noservices.stargatetarget the middleware leaves requests anonymous (auth-gated routes 401; the C#RpcExceptionpath degrades the same way with the dependency down). - Filesystem listener:
filesystem.file.updated.v1onfilesystem_eventsis consumed and acked; Ring's schema has no reference-typed jsonb columns, so the updater is a no-op (0 rows). - Migration DDL is idempotent (
CREATE ... IF NOT EXISTS, no DROPs) — thedyson_ringdatabase stays shared with the C# fleet until cutover.
- Blade config delta:
[services] metoer = { http = "http://localhost:8080", grpc = "localhost:9090" }; addmetoerto[endpoints] serviceNames. - Caddy delta so old clients keep calling
/ring/**:@ring path /ring/*→rewrite @ring /metoer{path}before the finalreverse_proxy(pattern from Stargate's Caddyfile). - Point the fleet's
services__ring__grpc__0env at Metoer's gRPC endpoint, then stop the C# Ring instances (same DB, NATS and Redis).
- Swagger UI/manifest is hand-built (byte-identity with Swashbuckle is a dev tool concern only).
metasnake-casing handles nestedmap[string]anykeys (STJ's policy applies toDictionaryinstances; passthrough of rawJsonElementsubtrees is not replicated).- The auth middleware uses the Go shared-cache format for
auth:profile:*(plain value vs the C# HSET envelope); both sides treat the other's format as a miss and refetch. - The in-process SOP stream queue is unbounded (slice-backed cond queue),
matching
Channel.CreateUnboundedsemantics.