Repository: google/exposure-notifications-server
Pull request #604 (2020-06-10 18:16:41)

include end time in export filenames
If we configure a "catch up file" at some factor as a more frequent
export configuration (e.g., 24 hr catch up, 4 hr regular period),
unless they use a different prefix/suffix in the name they'll clash
on naming. Easy way to make this foolproof is to have filenames
include start/end times.

This should not effect anybody that simply reads index for files to
download. This also shouldn't effect our sample app implementation
because its region is not configured for catch up files. But I will
also submit a corresponding change in the regex it uses to group
parts of a batch together:
https://github.com/google/exposure-notifications-android/blob/5eb8e99dc8ef0db4ee4754495d032e27094bcfe9/app/src/main/java/com/google/android/apps/exposurenotification/network/Uris.java#L66


Patch:
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index b973caa..fcbb235 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -15,14 +15,14 @@ jobs:
           This issue is stale because it has been open for 14 days with no
           activity. It will automatically close after 7 more days of inactivity.
         stale-issue-label: 'kind/stale'
-        exempt-issue-labels: 'priority/important-soon,kind/bug'
+        exempt-issue-labels: 'priority/important-soon,kind/bug,kind/privacy'
 
         stale-pr-message: |-
           This Pull Request is stale because it has been open for 14 days with
           no activity. It will automatically close after 7 more days of
           inactivity.
         stale-pr-label: 'kind/stale'
-        exempt-pr-labels: 'priority/important-soon,kind/bug'
+        exempt-pr-labels: 'priority/important-soon,kind/bug,kind/privacy'
 
         days-before-stale: 14
         days-before-close: 7
diff --git a/README.md b/README.md
index 08d46fd..7eb545f 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ Server here:
 * [Reference Documentation](https://pkg.go.dev/mod/github.com/google/exposure-notifications-server)
 * [Server Functional Requirements](https://google.github.io/exposure-notifications-server/server_functional_requirements)
 * [Server Deployment Options](https://google.github.io/exposure-notifications-server/server_deployment_options)
+* [API Definitions](pkg/api/v1alpha1)
 
 ## Issues and Questions
 
diff --git a/builders/migrate.yaml b/builders/migrate.yaml
index daf21aa..ea87255 100644
--- a/builders/migrate.yaml
+++ b/builders/migrate.yaml
@@ -48,6 +48,6 @@ steps:
 
     echo "==> Running migrations"
     /usr/local/bin/migrate \
-      -database postgres://${_DB_USER}:$$(cat ./dbpass)@localhost:${_DB_PORT}/${_DB_NAME}?sslmode=${_DB_SSLMODE} \
+      -database postgres://${_DB_USER}:$$(cat ./dbpass | urlencode)@localhost:${_DB_PORT}/${_DB_NAME}?sslmode=${_DB_SSLMODE} \
       -path migrations \
       ${_COMMAND}
diff --git a/cmd/debugger/main.go b/cmd/debugger/main.go
new file mode 100644
index 0000000..aef89fd
--- /dev/null
+++ b/cmd/debugger/main.go
@@ -0,0 +1,61 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package main runs the debugger service.
+package main
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/google/exposure-notifications-server/internal/debugger"
+	"github.com/google/exposure-notifications-server/internal/interrupt"
+	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/server"
+	"github.com/google/exposure-notifications-server/internal/setup"
+)
+
+func main() {
+	ctx, done := interrupt.Context()
+	defer done()
+
+	if err := realMain(ctx); err != nil {
+		logger := logging.FromContext(ctx)
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
+	logger := logging.FromContext(ctx)
+
+	var config debugger.Config
+	env, err := setup.Setup(ctx, &config)
+	if err != nil {
+		return fmt.Errorf("setup.Setup: %w", err)
+	}
+	defer env.Close(ctx)
+
+	debuggerServer, err := debugger.NewServer(&config, env)
+	if err != nil {
+		return fmt.Errorf("export.NewServer: %w", err)
+	}
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Infof("listening on :%s", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, debuggerServer.Routes(ctx))
+}
diff --git a/cmd/export/main.go b/cmd/export/main.go
index ac43c1b..c9d8236 100644
--- a/cmd/export/main.go
+++ b/cmd/export/main.go
@@ -18,7 +18,6 @@ package main
 import (
 	"context"
 	"fmt"
-	"net/http"
 
 	"github.com/google/exposure-notifications-server/internal/export"
 	"github.com/google/exposure-notifications-server/internal/interrupt"
@@ -53,15 +52,11 @@ func realMain(ctx context.Context) error {
 		return fmt.Errorf("export.NewServer: %w", err)
 	}
 
-	mux := http.NewServeMux()
-	mux.HandleFunc("/create-batches", batchServer.CreateBatchesHandler)
-	mux.HandleFunc("/do-work", batchServer.WorkerHandler)
-
 	srv, err := server.New(config.Port)
 	if err != nil {
 		return fmt.Errorf("server.New: %w", err)
 	}
 	logger.Infof("listening on :%s", config.Port)
 
-	return srv.ServeHTTPHandler(ctx, mux)
+	return srv.ServeHTTPHandler(ctx, batchServer.Routes(ctx))
 }
diff --git a/docker/migrate.dockerfile b/docker/migrate.dockerfile
index 9865839..9f8622c 100644
--- a/docker/migrate.dockerfile
+++ b/docker/migrate.dockerfile
@@ -27,10 +27,13 @@ RUN chmod +x /bin/cloud_sql_proxy
 RUN go get -tags 'postgres' -u github.com/golang-migrate/migrate/cmd/migrate
 
 FROM alpine
-RUN apk add --no-cache ca-certificates
+RUN apk add --no-cache bash ca-certificates
 
 COPY --from=builder /bin/cloud_sql_proxy /bin/cloud_sql_proxy
 COPY --from=builder /go/bin/migrate /usr/local/bin/migrate
 
+# Install urlencode - see https://github.com/golang-migrate/migrate/issues/396.
+ADD docker/urlencode /bin/urlencode
+RUN chmod +x /bin/urlencode
 
 ENTRYPOINT ["/usr/local/bin/migrate"]
diff --git a/docker/urlencode b/docker/urlencode
new file mode 100755
index 0000000..3468a4d
--- /dev/null
+++ b/docker/urlencode
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+# Copyright 2020 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eEuo pipefail
+
+read IN
+LENGTH="${#IN}"
+for (( i = 0; i < $LENGTH; i++ )); do
+  c="${IN:i:1}"
+  case $c in
+    [a-zA-Z0-9.~_-]) printf "$c" ;;
+    *) printf '%s' "$c" | xxd -p -c1 |
+      while read c; do printf '%%%s' "$c"; done ;;
+  esac
+done
diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml
index 7d132b0..815fd6d 100644
--- a/docs/_data/navigation.yml
+++ b/docs/_data/navigation.yml
@@ -17,8 +17,8 @@ docs:
           url: /server_deployment_options   
         - title: Server Functional Requirements
           url: /server_functional_requirements   
-        - title: "Verification Protocol Design"
+        - title: Verification Protocol Design
           url: /design/verification_protocol
-
-   
-    
\ No newline at end of file
+        - title: API Definitions
+          url: https://github.com/google/exposure-notifications-server/tree/master/pkg/api/v1alpha1
+  
diff --git a/docs/design/verification_protocol.md b/docs/design/verification_protocol.md
index 57c572d..7a61df8 100644
--- a/docs/design/verification_protocol.md
+++ b/docs/design/verification_protocol.md
@@ -32,16 +32,18 @@ health authority in the jurisdiction.
 
 1. App user is diagnosed with Covid-19. PHA issues a PIN code to the user.
 2. The user enters the PIN code on their App
-3. The app creates a HMAC value that combines the TEKs from the device along
+3. The App exchange the PIN for a claim check token and walks the user through
+   some health related questions (i.e. symptom onset date).
+4. The app creates a HMAC value that combines the TEKs from the device along
    with calculated transmission risk value, rolling period start and rolling
 	 period count values (the key data + metadata).
-	 These values are sent to the PHA server that issued the PIN.
-4. If the PIN is valid, the PHA issues a JWT that is signed using ECDSA over
+	 These values are sent to the PHA server along with the token from step 3.
+5. If the token is valid, the PHA issues a JWT that is signed using ECDSA over
    the P-256 elliptic curve with SHA-256 as a hash function. The JWT includes
    additional claims about the data (see below).
-5. The app on the user's device sends this signed JWT to the exposure
+6. The app on the user's device sends this signed JWT to the exposure
    notifications server (this project).
-6. If the JWT is valid and signed by a trusted PHA (verified because the public
+7. If the JWT is valid and signed by a trusted PHA (verified because the public
 	 key has been previously shared with the server), then the keys are imported
 	 into the server for distribution to other devices in the geography. Because
    we are using pre-shared public keys, this verification can be done with
@@ -62,7 +64,9 @@ First, using the standard claims.
 * `iss` : The issuer will be used to determine which public key(s) are valid for
 verification. This is to allow for key rotation.
 * `aud` : The audience must be as configured for this installation of the
-exposure notifications server.
+exposure notifications server. The operator of the exposure notifications server
+is the one to define this value and should be shared to all participating health
+authorities.
 * `iat` : The unix timestamp at which the token was issued.
 * `exp` : The unix timestamp at which the token will expire.
 * `nbf` : If present, the "not before" timestamp will be honored.
diff --git a/docs/index.md b/docs/index.md
index 31ea9fa..be987ad 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -48,6 +48,7 @@ Server here:
 * [Server Functional Requirements](server_functional_requirements.md)
 * [Server Deployment Options](server_deployment_options.md)
 * [Reference documentation](https://pkg.go.dev/mod/github.com/google/exposure-notifications-server)
+* [API Definitions](https://github.com/google/exposure-notifications-server/tree/master/pkg/api/v1alpha1)
 
 ## Issues and Questions
 
diff --git a/docs/server_functional_requirements.md b/docs/server_functional_requirements.md
index 594b7d2..91db401 100644
--- a/docs/server_functional_requirements.md
+++ b/docs/server_functional_requirements.md
@@ -99,11 +99,11 @@ Minimum required fields, followed by a JSON example:
   * Description: Name of the application being used to send the request. This
     is used to determine what app is uploading keys and if it is an allowed
     region for that app. (BundleID on iOS)
-* `verificationPayload` (**REQUIRED**)
+* `verificationPayload` (**REQUIRED FOR VERIFICATION PROTOCOL**)
   * Type: String
   * Description: verificationPayload is a signed certificate from a public
     health authority, indicating a confirmed diagnosis
-* `hmackey` (**REQUIRED**)
+* `hmackey` (**REQUIRED FOR VERIFICATION PROTOCOL**)
   * Type: String
   * Description: The device generated, random key that was used to create the
   HMAC for the data sent to the diagnosis verification server. The actual
@@ -162,7 +162,8 @@ generate the files in a single operation rather than on-demand, and distribute
 the files using a CDN.
 
 For information on the format of the batch file, see
-[Exposure Key Export File Format and Verification](https://www.google.com/covid19/exposurenotifications/pdfs/Exposure-Key-File-Format-and-Verification.pdf).
+[Exposure Key Export File Format and Verification](https://developers.google.com/android/exposure-notifications/exposure-key-file-format)
+and [Working with Export Files](https://github.com/google/exposure-notifications-server/tree/master/examples/export).
 
 The batch file generation should be per-region, incremental feeds of new data.
 While additional data can be included in the downloads, there is a minimum set
diff --git a/examples/export/README.md b/examples/export/README.md
index f9a3a10..d6141b8 100644
--- a/examples/export/README.md
+++ b/examples/export/README.md
@@ -1,7 +1,8 @@
 # Working with Export Files
 
 This demonstrates some utilities and commands to help understand the format documented in
-the [Exposure Key File Format documentation](https://www.google.com/covid19/exposurenotifications/pdfs/Exposure-Key-File-Format-and-Verification.pdf)
+the [Exposure Key File Format documentation](https://developers.google.com/android/exposure-notifications/exposure-key-file-format)
+and using the [export protobuf](https://github.com/google/exposure-notifications-server/blob/master/internal/pb/export/export.proto).
 
 ## Generating signing keys
 
diff --git a/go.mod b/go.mod
index 7bea422..2b50585 100644
--- a/go.mod
+++ b/go.mod
@@ -40,6 +40,7 @@ require (
 	github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d // indirect
 	github.com/jackc/pgx/v4 v4.6.0
 	github.com/jefferai/jsonx v1.0.1 // indirect
+	github.com/kelseyhightower/run v0.0.16
 	github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 // indirect
 	github.com/kr/text v0.2.0 // indirect
 	github.com/lib/pq v1.5.2 // indirect
@@ -65,6 +66,7 @@ require (
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c // indirect
 	go.opencensus.io v0.22.3
 	go.uber.org/zap v1.14.1
+	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
 	golang.org/x/tools v0.0.0-20200513154647-78b527d18275
 	google.golang.org/api v0.25.0
diff --git a/go.sum b/go.sum
index 93618c6..1f7ff5c 100644
--- a/go.sum
+++ b/go.sum
@@ -806,6 +806,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
 github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
 github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
+github.com/kelseyhightower/run v0.0.16 h1:j6KBSSQFjKvNO99+zs4tkqWCgNDBcF3kN9ghjOyhXHg=
+github.com/kelseyhightower/run v0.0.16/go.mod h1:qggbYejLh94f8K/ZWW+lAbp1tOVzNgrD4coywoKhTXw=
 github.com/keybase/go-crypto v0.0.0-20190403132359-d65b6b94177f h1:Gsc9mVHLRqBjMgdQCghN9NObCcRncDqxJvBvEaIIQEo=
 github.com/keybase/go-crypto v0.0.0-20190403132359-d65b6b94177f/go.mod h1:ghbZscTyKdM07+Fw3KSi0hcJm+AlEUWj8QLlPtijN/M=
 github.com/keybase/go-crypto v0.0.0-20200123153347-de78d2cb44f4 h1:cTxwSmnaqLoo+4tLukHoB9iqHOu3LmLhRmgUxZo6Vp4=
diff --git a/internal/admin/index/index.go b/internal/admin/index/index.go
index 0e461ff..32555bc 100644
--- a/internal/admin/index/index.go
+++ b/internal/admin/index/index.go
@@ -41,7 +41,7 @@ func (h *indexHandler) Execute(c *gin.Context) {
 
 	// Load authorized apps for index.
 	db := aadb.New(h.env.Database())
-	apps, err := db.GetAllAuthorizedApps(ctx, h.env.SecretManager())
+	apps, err := db.ListAuthorizedApps(ctx)
 	if err != nil {
 		admin.ErrorPage(c, err.Error())
 		return
diff --git a/internal/authorizedapp/database/authorized_app.go b/internal/authorizedapp/database/authorized_app.go
index 8f630f2..87254da 100644
--- a/internal/authorizedapp/database/authorized_app.go
+++ b/internal/authorizedapp/database/authorized_app.go
@@ -109,7 +109,7 @@ func (aa *AuthorizedAppDB) DeleteAuthorizedApp(ctx context.Context, name string)
 	return nil
 }
 
-func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.SecretManager) ([]*model.AuthorizedApp, error) {
+func (aa *AuthorizedAppDB) ListAuthorizedApps(ctx context.Context) ([]*model.AuthorizedApp, error) {
 	conn, err := aa.db.Pool.Acquire(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("acquiring connection: %w", err)
@@ -122,7 +122,7 @@ func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.
 			allowed_health_authority_ids, bypass_health_authority_verification
 		FROM
 			AuthorizedApp
-		ORDER BY app_package_name ASC`
+		ORDER BY LOWER(app_package_name) ASC`
 
 	rows, err := conn.Query(ctx, query)
 	if err != nil {
@@ -136,7 +136,7 @@ func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.
 			return nil, fmt.Errorf("iterating rows: %w", err)
 		}
 
-		app, err := scanOneAuthorizedApp(ctx, rows, sm)
+		app, err := scanOneAuthorizedApp(ctx, rows)
 		if err != nil {
 			return nil, fmt.Errorf("error reading authorized apps: %w", err)
 		}
@@ -164,10 +164,10 @@ func (aa *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.Secr
 
 	row := conn.QueryRow(ctx, query, name)
 
-	return scanOneAuthorizedApp(ctx, row, sm)
+	return scanOneAuthorizedApp(ctx, row)
 }
 
-func scanOneAuthorizedApp(ctx context.Context, row pgx.Row, sm secrets.SecretManager) (*model.AuthorizedApp, error) {
+func scanOneAuthorizedApp(ctx context.Context, row pgx.Row) (*model.AuthorizedApp, error) {
 	config := model.NewAuthorizedApp()
 	var allowedRegions []string
 	var allowedHealthAuthorityIDs []int64
diff --git a/internal/database/config.go b/internal/database/config.go
index 4159745..46282c1 100644
--- a/internal/database/config.go
+++ b/internal/database/config.go
@@ -19,21 +19,21 @@ import (
 )
 
 type Config struct {
-	Name               string        `env:"DB_NAME"`
-	User               string        `env:"DB_USER"`
-	Host               string        `env:"DB_HOST, default=localhost"`
-	Port               string        `env:"DB_PORT, default=5432"`
-	SSLMode            string        `env:"DB_SSLMODE, default=require"`
-	ConnectionTimeout  int           `env:"DB_CONNECT_TIMEOUT"`
+	Name               string        `env:"DB_NAME" json:",omitempty"`
+	User               string        `env:"DB_USER" json:",omitempty"`
+	Host               string        `env:"DB_HOST, default=localhost" json:",omitempty"`
+	Port               string        `env:"DB_PORT, default=5432" json:",omitempty"`
+	SSLMode            string        `env:"DB_SSLMODE, default=require" json:",omitempty"`
+	ConnectionTimeout  int           `env:"DB_CONNECT_TIMEOUT" json:",omitempty"`
 	Password           string        `env:"DB_PASSWORD" json:"-"` // ignored by zap's JSON formatter
-	SSLCertPath        string        `env:"DB_SSLCERT"`
-	SSLKeyPath         string        `env:"DB_SSLKEY"`
-	SSLRootCertPath    string        `env:"DB_SSLROOTCERT"`
-	PoolMinConnections string        `env:"DB_POOL_MIN_CONNS"`
-	PoolMaxConnections string        `env:"DB_POOL_MAX_CONNS"`
-	PoolMaxConnLife    time.Duration `env:"DB_POOL_MAX_CONN_LIFETIME"`
-	PoolMaxConnIdle    time.Duration `env:"DB_POOL_MAX_CONN_IDLE_TIME"`
-	PoolHealthCheck    time.Duration `env:"DB_POOL_HEALTH_CHECK_PERIOD"`
+	SSLCertPath        string        `env:"DB_SSLCERT" json:",omitempty"`
+	SSLKeyPath         string        `env:"DB_SSLKEY" json:",omitempty"`
+	SSLRootCertPath    string        `env:"DB_SSLROOTCERT" json:",omitempty"`
+	PoolMinConnections string        `env:"DB_POOL_MIN_CONNS" json:",omitempty"`
+	PoolMaxConnections string        `env:"DB_POOL_MAX_CONNS" json:",omitempty"`
+	PoolMaxConnLife    time.Duration `env:"DB_POOL_MAX_CONN_LIFETIME" json:",omitempty"`
+	PoolMaxConnIdle    time.Duration `env:"DB_POOL_MAX_CONN_IDLE_TIME" json:",omitempty"`
+	PoolHealthCheck    time.Duration `env:"DB_POOL_HEALTH_CHECK_PERIOD" json:",omitempty"`
 }
 
 func (c *Config) DatabaseConfig() *Config {
diff --git a/internal/debugger/config.go b/internal/debugger/config.go
new file mode 100644
index 0000000..6953b79
--- /dev/null
+++ b/internal/debugger/config.go
@@ -0,0 +1,62 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package debugger
+
+import (
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+// Compile-time check to assert this config matches requirements.
+var _ setup.AuthorizedAppConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.KeyManagerConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
+
+// Config represents the configuration and associated environment variables.
+type Config struct {
+	AuthorizedApp authorizedapp.Config
+	Database      database.Config
+	KeyManager    signing.Config
+	SecretManager secrets.Config
+	Storage       storage.Config
+
+	Port string `env:"PORT, default=8080"`
+}
+
+func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
+	return &c.AuthorizedApp
+}
+
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return &c.Storage
+}
+
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
+}
+
+func (c *Config) KeyManagerConfig() *signing.Config {
+	return &c.KeyManager
+}
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
+}
diff --git a/internal/debugger/debugger.go b/internal/debugger/debugger.go
new file mode 100644
index 0000000..ace5e78
--- /dev/null
+++ b/internal/debugger/debugger.go
@@ -0,0 +1,18 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package debugger is a server-side debugger component that displays debug
+// information about the system. It is not designed to be deployed in production
+// scenarios.
+package debugger
diff --git a/internal/debugger/handler_debug.go b/internal/debugger/handler_debug.go
new file mode 100644
index 0000000..ca94a16
--- /dev/null
+++ b/internal/debugger/handler_debug.go
@@ -0,0 +1,268 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package debugger
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"sync"
+	"time"
+
+	authorizedappdatabase "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
+	authorizedappmodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	exportdatabase "github.com/google/exposure-notifications-server/internal/export/database"
+	exportmodel "github.com/google/exposure-notifications-server/internal/export/model"
+	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/kelseyhightower/run"
+	"golang.org/x/oauth2/google"
+	"google.golang.org/api/iam/v1"
+)
+
+func (s *Server) handleDebug(ctx context.Context) http.HandlerFunc {
+	db := s.env.Database()
+	authorizedappDB := authorizedappdatabase.New(db)
+	exportDB := exportdatabase.New(db)
+	logger := logging.FromContext(ctx)
+
+	services := []string{
+		"cleanup-export",
+		"cleanup-exposure",
+		"export",
+		"exposure",
+		"federationin",
+		"federationout",
+		"generate",
+	}
+
+	type response struct {
+		ServiceEnvironment map[string]map[string]string
+
+		AuthorizedApps []*authorizedappmodel.AuthorizedApp
+
+		ExportConfigs    []*exportmodel.ExportConfig
+		ExportBatchEnds  map[int64]*time.Time
+		ExportBatchFiles []string
+
+		SignatureInfos []*exportmodel.SignatureInfo
+	}
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+
+		errCh := make(chan error, 1)
+
+		var wg sync.WaitGroup
+		var ml sync.Mutex
+
+		resp := &response{
+			ServiceEnvironment: make(map[string]map[string]string),
+			ExportBatchEnds:    make(map[int64]*time.Time),
+		}
+
+		for _, service := range services {
+			service := service
+
+			queue(&wg, errCh, func() error {
+				env, err := cloudRunEnv(ctx, service)
+				if err != nil {
+					return fmt.Errorf("failed to lookup environment for %s: %w", service, err)
+				}
+				ml.Lock()
+				resp.ServiceEnvironment[service] = env
+				ml.Unlock()
+				return nil
+			})
+		}
+
+		queue(&wg, errCh, func() error {
+			authorizedApps, err := authorizedappDB.ListAuthorizedApps(ctx)
+			if err != nil {
+				return fmt.Errorf("failed to lookup authorized app configs: %w", err)
+			}
+			resp.AuthorizedApps = authorizedApps
+			return nil
+		})
+
+		queue(&wg, errCh, func() error {
+			exportConfigs, err := exportDB.GetAllExportConfigs(ctx)
+			if err != nil {
+				return fmt.Errorf("failed to get all export configs: %w", err)
+			}
+			resp.ExportConfigs = exportConfigs
+			return nil
+		})
+
+		queue(&wg, errCh, func() error {
+			exportBatchEnds, err := exportDB.ListLatestExportBatchEnds(ctx)
+			if err != nil {
+				return fmt.Errorf("failed to get latest export batch ends: %w", err)
+			}
+			resp.ExportBatchEnds = exportBatchEnds
+			return nil
+		})
+
+		queue(&wg, errCh, func() error {
+			exportBatchFiles, err := exportDB.LookupExportFiles(ctx, 4*time.Hour)
+			if err != nil {
+				return fmt.Errorf("failed to lookup export files: %w", err)
+			}
+			resp.ExportBatchFiles = exportBatchFiles
+			return nil
+		})
+
+		queue(&wg, errCh, func() error {
+			signatureInfos, err := exportDB.ListAllSigntureInfos(ctx)
+			if err != nil {
+				return fmt.Errorf("failed to list signature infos: %w", err)
+			}
+			resp.SignatureInfos = signatureInfos
+			return nil
+		})
+
+		// Wait for all the queues to be done or for an error to occur.
+		wgDone := make(chan struct{})
+		go func() {
+			wg.Wait()
+			close(wgDone)
+		}()
+
+		select {
+		case <-wgDone:
+		case err := <-errCh:
+			logger.Error(err)
+			w.WriteHeader(http.StatusInternalServerError)
+			fmt.Fprint(w, http.StatusText(http.StatusInternalServerError))
+			return
+		}
+
+		// Marshall the json.
+		b, err := json.MarshalIndent(resp, "", "  ")
+		if err != nil {
+			logger.Errorf("failed to marshal json: %v", err)
+			w.WriteHeader(http.StatusInternalServerError)
+			fmt.Fprint(w, http.StatusText(http.StatusInternalServerError))
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		fmt.Fprintf(w, "%s", b)
+	}
+}
+
+// queue creates a job, adding it to the given wg. Errors are pushed onto the
+// provided errCh unless it's closed.
+func queue(wg *sync.WaitGroup, errCh chan<- error, f func() error) {
+	wg.Add(1)
+
+	go func() {
+		defer wg.Done()
+
+		if err := f(); err != nil {
+			select {
+			case errCh <- err:
+			default:
+			}
+		}
+	}()
+}
+
+func cloudRunEnv(ctx context.Context, name string) (map[string]string, error) {
+	client, err := google.DefaultClient(ctx, iam.CloudPlatformScope)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create client: %w", err)
+	}
+	client.Timeout = 5 * time.Second
+
+	// Assume the target service is the same project (run caches this).
+	project, err := run.ProjectID()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get project: %w", err)
+	}
+
+	// Assume the target service is the same region (run caches this).
+	region, err := run.Region()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get region: %w", err)
+	}
+
+	// Lookup service to get revision
+	serviceURL := fmt.Sprintf("https://%s-run.googleapis.com/apis/serving.knative.dev/v1/namespaces/%s/services/%s",
+		region, project, name)
+	serviceResp, err := client.Get(serviceURL)
+	if err != nil {
+		return nil, fmt.Errorf("failed to lookup service: %w", err)
+	}
+	defer serviceResp.Body.Close()
+
+	if serviceResp.StatusCode != 200 {
+		b, _ := ioutil.ReadAll(serviceResp.Body)
+		return nil, fmt.Errorf("failed to lookup service: %d: %s", serviceResp.StatusCode, b)
+	}
+
+	var s cloudRunService
+	if err := json.NewDecoder(serviceResp.Body).Decode(&s); err != nil {
+		return nil, fmt.Errorf("failed to parse service as json: %w", err)
+	}
+
+	// Lookup revision to get environment.
+	revisionURL := fmt.Sprintf("https://%s-run.googleapis.com/apis/serving.knative.dev/v1/namespaces/%s/revisions/%s",
+		region, project, s.Status.Revision)
+	revisionResp, err := client.Get(revisionURL)
+	if err != nil {
+		return nil, fmt.Errorf("failed to lookup revision: %w", err)
+	}
+	defer revisionResp.Body.Close()
+
+	if revisionResp.StatusCode != 200 {
+		b, _ := ioutil.ReadAll(revisionResp.Body)
+		return nil, fmt.Errorf("failed to lookup revision: %d: %s", revisionResp.StatusCode, b)
+	}
+
+	var r cloudRunRevision
+	if err := json.NewDecoder(revisionResp.Body).Decode(&r); err != nil {
+		return nil, fmt.Errorf("failed to parse revision as json: %w", err)
+	}
+
+	if len(r.Spec.Containers) == 0 {
+		return nil, fmt.Errorf("no containers: %#v", r)
+	}
+	container := r.Spec.Containers[0]
+
+	envvars := make(map[string]string, len(container.Env))
+	for _, env := range container.Env {
+		envvars[env.Name] = env.Value
+	}
+	return envvars, nil
+}
+
+type cloudRunService struct {
+	Status struct {
+		Revision string `json:"latestReadyRevisionName"`
+	} `json:"status"`
+}
+
+type cloudRunRevision struct {
+	Spec struct {
+		Containers []struct {
+			Env []struct {
+				Name  string `json:"name"`
+				Value string `json:"value"`
+			} `json:"env"`
+		} `json:"containers"`
+	} `json:"spec"`
+}
diff --git a/internal/debugger/server.go b/internal/debugger/server.go
new file mode 100644
index 0000000..4fda7b9
--- /dev/null
+++ b/internal/debugger/server.go
@@ -0,0 +1,59 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package debugger
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+// Server is the debugger server.
+type Server struct {
+	config *Config
+	env    *serverenv.ServerEnv
+}
+
+// NewServer makes a new debugger server.
+func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
+	if env.AuthorizedAppProvider() == nil {
+		return nil, fmt.Errorf("missing AuthorizedAppProvider in server env")
+	}
+	if env.Blobstore() == nil {
+		return nil, fmt.Errorf("missing Blobstore in server env")
+	}
+	if env.Database() == nil {
+		return nil, fmt.Errorf("missing Database in server env")
+	}
+	if env.KeyManager() == nil {
+		return nil, fmt.Errorf("missing KeyManager in server env")
+	}
+	if env.SecretManager() == nil {
+		return nil, fmt.Errorf("missing SecretManager in server env")
+	}
+
+	return &Server{
+		config: config,
+		env:    env,
+	}, nil
+}
+
+func (s *Server) Routes(ctx context.Context) *http.ServeMux {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", s.handleDebug(ctx))
+	return mux
+}
diff --git a/internal/export/batcher.go b/internal/export/batcher.go
index ec02caa..3247e96 100644
--- a/internal/export/batcher.go
+++ b/internal/export/batcher.go
@@ -22,84 +22,89 @@ import (
 	"time"
 
 	coredb "github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/export/database"
+	exportdatabase "github.com/google/exposure-notifications-server/internal/export/database"
 	"github.com/google/exposure-notifications-server/internal/export/model"
 	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
 )
 
-// CreateBatchesHandler is a handler to iterate the rows of ExportConfig and
+// handleCreateBatches is a handler to iterate the rows of ExportConfig and
 // create entries in ExportBatchJob as appropriate.
-func (s *Server) CreateBatchesHandler(w http.ResponseWriter, r *http.Request) {
-	ctx, cancel := context.WithTimeout(r.Context(), s.config.CreateTimeout)
-	defer cancel()
+func (s *Server) handleCreateBatches(ctx context.Context) http.HandlerFunc {
 	logger := logging.FromContext(ctx)
 	metrics := s.env.MetricsExporter(ctx)
-
-	// Obtain lock to make sure there are no other processes working to create batches.
-	lock := "create_batches"
-	unlockFn, err := s.db.Lock(ctx, lock, s.config.CreateTimeout)
-	if err != nil {
-		if errors.Is(err, coredb.ErrAlreadyLocked) {
-			metrics.WriteInt("export-batcher-lock-contention", true, 1)
-			msg := fmt.Sprintf("Lock %s already in use, no work will be performed", lock)
-			logger.Infof(msg)
-			fmt.Fprint(w, msg) // We return status 200 here so that Cloud Scheduler does not retry.
+	db := s.env.Database()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		ctx, cancel := context.WithTimeout(r.Context(), s.config.CreateTimeout)
+		defer cancel()
+
+		// Obtain lock to make sure there are no other processes working to create batches.
+		lock := "create_batches"
+		unlockFn, err := db.Lock(ctx, lock, s.config.CreateTimeout)
+		if err != nil {
+			if errors.Is(err, coredb.ErrAlreadyLocked) {
+				metrics.WriteInt("export-batcher-lock-contention", true, 1)
+				msg := fmt.Sprintf("Lock %s already in use, no work will be performed", lock)
+				logger.Infof(msg)
+				fmt.Fprint(w, msg) // We return status 200 here so that Cloud Scheduler does not retry.
+				return
+			}
+			logger.Errorf("Could not acquire lock %s: %v", lock, err)
+			http.Error(w, fmt.Sprintf("Could not acquire lock %s, check logs.", lock), http.StatusInternalServerError)
 			return
 		}
-		logger.Errorf("Could not acquire lock %s: %v", lock, err)
-		http.Error(w, fmt.Sprintf("Could not acquire lock %s, check logs.", lock), http.StatusInternalServerError)
-		return
-	}
-	defer func() {
-		if err := unlockFn(); err != nil {
-			logger.Errorf("failed to unlock: %v", err)
-		}
-	}()
-
-	totalConfigs := 0
-	totalBatches := 0
-	totalConfigsWithBatches := 0
-	defer func() {
-		logger.Infof("Processed %d configs creating %d batches across %d configs", totalConfigs, totalBatches, totalConfigsWithBatches)
-	}()
-
-	effectiveTime := time.Now().Add(-1 * s.config.MinWindowAge)
-	err = database.New(s.db).IterateExportConfigs(ctx, effectiveTime, func(ec *model.ExportConfig) error {
-		totalConfigs++
-		if batchesCreated, err := s.maybeCreateBatches(ctx, ec, effectiveTime); err != nil {
-			logger.Errorf("Failed to create batches for config %d: %v, continuing to next config", ec.ConfigID, err)
-		} else {
-			totalBatches += batchesCreated
-			if batchesCreated > 0 {
-				totalConfigsWithBatches++
+		defer func() {
+			if err := unlockFn(); err != nil {
+				logger.Errorf("failed to unlock: %v", err)
+			}
+		}()
+
+		totalConfigs := 0
+		totalBatches := 0
+		totalConfigsWithBatches := 0
+		defer func() {
+			logger.Infof("Processed %d configs creating %d batches across %d configs", totalConfigs, totalBatches, totalConfigsWithBatches)
+		}()
+
+		effectiveTime := time.Now().Add(-1 * s.config.MinWindowAge)
+		err = exportdatabase.New(db).IterateExportConfigs(ctx, effectiveTime, func(ec *model.ExportConfig) error {
+			totalConfigs++
+			if batchesCreated, err := s.maybeCreateBatches(ctx, ec, effectiveTime); err != nil {
+				logger.Errorf("Failed to create batches for config %d: %v, continuing to next config", ec.ConfigID, err)
+			} else {
+				totalBatches += batchesCreated
+				if batchesCreated > 0 {
+					totalConfigsWithBatches++
+				}
 			}
+			return nil
+		})
+		if err != nil {
+			// some specific error handling below, but just need one metric.
+			metrics.WriteInt("export-batcher-failed", true, 1)
+		}
+		switch {
+		case err == nil:
+			return
+		case errors.Is(err, context.DeadlineExceeded):
+			logger.Infof("Timed out creating batches, batch creation will continue on next invocation")
+		case errors.Is(err, context.Canceled):
+			logger.Infof("Canceled while creating batches, batch creation will continue on next invocation")
+		default:
+			logger.Errorf("creating batches: %v", err)
+			http.Error(w, "Failed to create batches, check logs.", http.StatusInternalServerError)
 		}
-		return nil
-	})
-	if err != nil {
-		// some specific error handling below, but just need one metric.
-		metrics.WriteInt("export-batcher-failed", true, 1)
-	}
-	switch {
-	case err == nil:
-		return
-	case errors.Is(err, context.DeadlineExceeded):
-		logger.Infof("Timed out creating batches, batch creation will continue on next invocation")
-	case errors.Is(err, context.Canceled):
-		logger.Infof("Canceled while creating batches, batch creation will continue on next invocation")
-	default:
-		logger.Errorf("creating batches: %v", err)
-		http.Error(w, "Failed to create batches, check logs.", http.StatusInternalServerError)
 	}
 }
 
 func (s *Server) maybeCreateBatches(ctx context.Context, ec *model.ExportConfig, now time.Time) (int, error) {
 	logger := logging.FromContext(ctx)
 	metrics := s.env.MetricsExporter(ctx)
+	db := s.env.Database()
 
-	latestEnd, err := database.New(s.db).LatestExportBatchEnd(ctx, ec)
+	latestEnd, err := exportdatabase.New(db).LatestExportBatchEnd(ctx, ec)
 	if err != nil {
 		return 0, fmt.Errorf("fetching most recent batch for config %d: %w", ec.ConfigID, err)
 	}
@@ -128,7 +133,7 @@ func (s *Server) maybeCreateBatches(ctx context.Context, ec *model.ExportConfig,
 		})
 	}
 
-	if err := database.New(s.db).AddExportBatches(ctx, batches); err != nil {
+	if err := exportdatabase.New(db).AddExportBatches(ctx, batches); err != nil {
 		return 0, fmt.Errorf("creating export batches for config %d: %w", ec.ConfigID, err)
 	}
 
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index 527b647..e57a73f 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -119,13 +119,14 @@ func (db *ExportDB) GetAllExportConfigs(ctx context.Context) ([]*model.ExportCon
 		SELECT
 			config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions
 		FROM
-			ExportConfig`)
+			ExportConfig
+		ORDER BY config_id`)
 	if err != nil {
 		return nil, err
 	}
 	defer rows.Close()
 
-	results := []*model.ExportConfig{}
+	var results []*model.ExportConfig
 	for rows.Next() {
 		ec, err := scanOneExportConfig(rows)
 		if err != nil {
@@ -358,13 +359,11 @@ func (db *ExportDB) LatestExportBatchEnd(ctx context.Context, ec *model.ExportCo
 
 	row := conn.QueryRow(ctx, `
 		SELECT
-			end_timestamp
+			MAX(end_timestamp)
 		FROM
 			ExportBatch
 		WHERE
-		    config_id = $1
-		ORDER BY
-		    end_timestamp DESC
+			config_id = $1
 		LIMIT 1
 		`, ec.ConfigID)
 
@@ -378,6 +377,43 @@ func (db *ExportDB) LatestExportBatchEnd(ctx context.Context, ec *model.ExportCo
 	return latestEnd, nil
 }
 
+// ListLatestExportBatchEnds returns a map of export config IDs to their latest
+// batch end times.
+func (db *ExportDB) ListLatestExportBatchEnds(ctx context.Context) (map[int64]*time.Time, error) {
+	conn, err := db.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	rows, err := conn.Query(ctx, `
+		SELECT
+			config_id, MAX(end_timestamp)
+		FROM
+			ExportBatch
+		GROUP BY config_id
+	`)
+	if err != nil {
+		return nil, err
+	}
+
+	m := make(map[int64]*time.Time)
+	for rows.Next() {
+		if rows.Err() != nil {
+			return nil, rows.Err()
+		}
+
+		var configID int64
+		var ts time.Time
+		if err := rows.Scan(&configID, &ts); err != nil {
+			return nil, err
+		}
+		m[configID] = &ts
+	}
+
+	return m, nil
+}
+
 // AddExportBatches inserts new export batches.
 func (db *ExportDB) AddExportBatches(ctx context.Context, batches []*model.ExportBatch) error {
 	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index 60b924c..5bcc83b 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -58,8 +58,8 @@ func (ec *ExportConfig) Validate() error {
 	if ec.Period > oneDay {
 		return errors.New("maximum period is 24h")
 	}
-	if ec.Period == 0 {
-		return errors.New("period must be non-zero")
+	if ec.Period < 1*time.Second {
+		return errors.New("period must be at least 1 second")
 	}
 	if int64(oneDay.Seconds())%int64(ec.Period.Seconds()) != 0 {
 		return errors.New("period must divide equally into 24 hours (e.g., 2h, 4h, 12h, 15m, 30m)")
diff --git a/internal/export/server.go b/internal/export/server.go
index e759787..2fb35b0 100644
--- a/internal/export/server.go
+++ b/internal/export/server.go
@@ -15,11 +15,9 @@
 package export
 
 import (
+	"context"
 	"fmt"
-
-	coredb "github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/export/database"
-	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+	"net/http"
 
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 )
@@ -30,6 +28,9 @@ func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
 	if env.Blobstore() == nil {
 		return nil, fmt.Errorf("export.NewBatchServer requires Blobstore present in the ServerEnv")
 	}
+	if env.Database() == nil {
+		return nil, fmt.Errorf("export.NewBatchServer requires Database present in the ServerEnv")
+	}
 	if env.KeyManager() == nil {
 		return nil, fmt.Errorf("export.NewBatchServer requires KeyManager present in the ServerEnv")
 	}
@@ -38,19 +39,23 @@ func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
 	}
 
 	return &Server{
-		db:        env.Database(),
-		exportdb:  database.New(env.Database()),
-		publishdb: publishdb.New(env.Database()),
-		config:    config,
-		env:       env,
+		config: config,
+		env:    env,
 	}, nil
 }
 
 // Server hosts end points to manage export batches.
 type Server struct {
-	db        *coredb.DB
-	exportdb  *database.ExportDB
-	publishdb *publishdb.PublishDB
-	config    *Config
-	env       *serverenv.ServerEnv
+	config *Config
+	env    *serverenv.ServerEnv
+}
+
+// Routes defines and returns the routes for this server.
+func (s *Server) Routes(ctx context.Context) *http.ServeMux {
+	mux := http.NewServeMux()
+
+	mux.HandleFunc("/create-batches", s.handleCreateBatches(ctx))
+	mux.HandleFunc("/do-work", s.handleDoWork(ctx))
+
+	return mux
 }
diff --git a/internal/export/worker.go b/internal/export/worker.go
index 0bb5d28..1565c07 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -26,8 +26,8 @@ import (
 	"time"
 
 	coredb "github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/export/database"
-	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+	exportdatabase "github.com/google/exposure-notifications-server/internal/export/database"
+	publishdatabase "github.com/google/exposure-notifications-server/internal/publish/database"
 
 	"github.com/google/exposure-notifications-server/internal/export/model"
 	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
@@ -43,55 +43,60 @@ const (
 	blobOperationTimeout = 50 * time.Second
 )
 
-// WorkerHandler is a handler to iterate the rows of ExportBatch, and creates
+// handleDoWork is a handler to iterate the rows of ExportBatch, and creates
 // export files.
-func (s *Server) WorkerHandler(w http.ResponseWriter, r *http.Request) {
-	ctx, cancel := context.WithTimeout(r.Context(), s.config.WorkerTimeout)
-	defer cancel()
+func (s *Server) handleDoWork(ctx context.Context) http.HandlerFunc {
 	logger := logging.FromContext(ctx)
-	exportDB := database.New(s.db)
+	db := s.env.Database()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		ctx, cancel := context.WithTimeout(r.Context(), s.config.WorkerTimeout)
+		defer cancel()
+
+		emitIndexForEmptyBatch := true
+		for {
+			if ctx.Err() != nil {
+				msg := "Timed out processing batches. Will continue on next invocation."
+				logger.Info(msg)
+				fmt.Fprintln(w, msg)
+				return
+			}
 
-	emitIndexForEmptyBatch := true
-	for {
-		if ctx.Err() != nil {
-			msg := "Timed out processing batches. Will continue on next invocation."
-			logger.Info(msg)
-			fmt.Fprintln(w, msg)
-			return
-		}
+			// Check for a batch and obtain a lease for it.
+			batch, err := exportdatabase.New(db).LeaseBatch(ctx, s.config.WorkerTimeout, time.Now())
+			if err != nil {
+				logger.Errorf("Failed to lease batch: %v", err)
+				continue
+			}
+			if batch == nil {
+				msg := "No more work to do"
+				logger.Info(msg)
+				fmt.Fprintln(w, msg)
+				return
+			}
 
-		// Check for a batch and obtain a lease for it.
-		batch, err := exportDB.LeaseBatch(ctx, s.config.WorkerTimeout, time.Now())
-		if err != nil {
-			logger.Errorf("Failed to lease batch: %v", err)
-			continue
-		}
-		if batch == nil {
-			msg := "No more work to do"
-			logger.Info(msg)
-			fmt.Fprintln(w, msg)
-			return
-		}
+			if err = s.exportBatch(ctx, batch, emitIndexForEmptyBatch); err != nil {
+				logger.Errorf("Failed to create files for batch: %v.", err)
+				continue
+			}
+			// We re-write the index file for empty batches for self-healing so that the
+			// index file reflects the ExportFile table in database. However, if a
+			// single worker processes a number of empty batches quickly, we want to
+			// avoid writing the same file repeatedly and hitting a rate limit.
+			emitIndexForEmptyBatch = false
 
-		if err = s.exportBatch(ctx, batch, emitIndexForEmptyBatch); err != nil {
-			logger.Errorf("Failed to create files for batch: %v.", err)
-			continue
+			fmt.Fprintf(w, "Batch %d marked completed. \n", batch.BatchID)
 		}
-		// We re-write the index file for empty batches for self-healing so that the
-		// index file reflects the ExportFile table in database. However, if a
-		// single worker processes a number of empty batches quickly, we want to
-		// avoid writing the same file repeatedly and hitting a rate limit.
-		emitIndexForEmptyBatch = false
-
-		fmt.Fprintf(w, "Batch %d marked completed. \n", batch.BatchID)
 	}
 }
 
 func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitIndexForEmptyBatch bool) error {
 	logger := logging.FromContext(ctx)
+	db := s.env.Database()
+
 	logger.Infof("Processing export batch %d (root: %q, region: %s), max records per file %d", eb.BatchID, eb.FilenameRoot, eb.OutputRegion, s.config.MaxRecords)
 
-	criteria := publishdb.IterateExposuresCriteria{
+	criteria := publishdatabase.IterateExposuresCriteria{
 		SinceTimestamp:      eb.StartTimestamp,
 		UntilTimestamp:      eb.EndTimestamp,
 		IncludeRegions:      eb.EffectiveInputRegions(),
@@ -105,7 +110,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	var groups [][]*publishmodel.Exposure
 	var exposures []*publishmodel.Exposure
 
-	_, err := s.publishdb.IterateExposures(ctx, criteria, func(exp *publishmodel.Exposure) error {
+	_, err := publishdatabase.New(db).IterateExposures(ctx, criteria, func(exp *publishmodel.Exposure) error {
 		exposures = append(exposures, exp)
 		if len(exposures) == s.config.MaxRecords {
 			groups = append(groups, exposures)
@@ -132,7 +137,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	}
 
 	// Load the non-expired signature infos associated with this export batch.
-	sigInfos, err := s.exportdb.LookupSignatureInfos(ctx, eb.SignatureInfoIDs, time.Now())
+	sigInfos, err := exportdatabase.New(db).LookupSignatureInfos(ctx, eb.SignatureInfoIDs, time.Now())
 	if err != nil {
 		return fmt.Errorf("error loading signature info for batch %d, %w", eb.BatchID, err)
 	}
@@ -171,7 +176,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	}
 
 	// Write the files records in database and complete the batch.
-	if err := s.exportdb.FinalizeBatch(ctx, eb, objectNames, batchSize); err != nil {
+	if err := exportdatabase.New(db).FinalizeBatch(ctx, eb, objectNames, batchSize); err != nil {
 		return fmt.Errorf("completing batch: %w", err)
 	}
 	logger.Infof("Batch %d completed", eb.BatchID)
@@ -219,6 +224,7 @@ func (s *Server) createFile(ctx context.Context, cfi createFileInfo) (string, er
 // We use a lock to make them line up after one another.
 func (s *Server) retryingCreateIndex(ctx context.Context, eb *model.ExportBatch, objectNames []string) error {
 	logger := logging.FromContext(ctx)
+	db := s.env.Database()
 
 	lockID := fmt.Sprintf("export-batch-%d", eb.BatchID)
 	sleep := 10 * time.Second
@@ -228,7 +234,7 @@ func (s *Server) retryingCreateIndex(ctx context.Context, eb *model.ExportBatch,
 			return nil
 		}
 
-		unlock, err := s.db.Lock(ctx, lockID, time.Minute)
+		unlock, err := db.Lock(ctx, lockID, time.Minute)
 		if err != nil {
 			if errors.Is(err, coredb.ErrAlreadyLocked) {
 				logger.Debugf("Lock %s is locked; sleeping %v and will try again", lockID, sleep)
@@ -255,8 +261,9 @@ func (s *Server) retryingCreateIndex(ctx context.Context, eb *model.ExportBatch,
 }
 
 func (s *Server) createIndex(ctx context.Context, eb *model.ExportBatch, newObjectNames []string) (string, int, error) {
-	exportDB := database.New(s.db)
-	objects, err := exportDB.LookupExportFiles(ctx, s.config.TTL)
+	db := s.env.Database()
+
+	objects, err := exportdatabase.New(db).LookupExportFiles(ctx, s.config.TTL)
 	if err != nil {
 		return "", 0, fmt.Errorf("lookup available export files: %w", err)
 	}
@@ -287,7 +294,7 @@ func (s *Server) createIndex(ctx context.Context, eb *model.ExportBatch, newObje
 }
 
 func exportFilename(eb *model.ExportBatch, batchNum int) string {
-	return fmt.Sprintf("%s/%d-%05d%s", eb.FilenameRoot, eb.StartTimestamp.Unix(), batchNum, filenameSuffix)
+	return fmt.Sprintf("%s/%d-%d-%05d%s", eb.FilenameRoot, eb.StartTimestamp.Unix(), eb.EndTimestamp.Unix(), batchNum, filenameSuffix)
 }
 
 func exportIndexFilename(eb *model.ExportBatch) string {
diff --git a/internal/federationin/federationin.go b/internal/federationin/federationin.go
index ad6b798..18a03ae 100644
--- a/internal/federationin/federationin.go
+++ b/internal/federationin/federationin.go
@@ -189,13 +189,17 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	timeoutContext, cancel := context.WithTimeout(ctx, h.config.Timeout)
 	defer cancel()
 
-	deps := pullDependencies{
-		fetch:               client.Fetch,
-		insertExposures:     h.publishdb.InsertExposures,
-		startFederationSync: h.db.StartFederationInSync,
+	opts := pullOptions{
+		deps: pullDependencies{
+			fetch:               client.Fetch,
+			insertExposures:     h.publishdb.InsertExposures,
+			startFederationSync: h.db.StartFederationInSync,
+		},
+		query:          query,
+		batchStart:     time.Now(),
+		truncateWindow: h.config.TruncateWindow,
 	}
-	batchStart := time.Now()
-	if err := pull(timeoutContext, metrics, deps, query, batchStart, h.config.TruncateWindow); err != nil {
+	if err := pull(timeoutContext, metrics, &opts); err != nil {
 		internalErrorf(ctx, w, "Federation query %q failed: %v", queryID, err)
 		return
 	}
@@ -205,7 +209,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies, q *model.FederationInQuery, batchStart time.Time, truncateWindow time.Duration) (err error) {
+type pullOptions struct {
+	deps           pullDependencies
+	query          *model.FederationInQuery
+	batchStart     time.Time
+	truncateWindow time.Duration
+}
+
+func pull(ctx context.Context, metrics metrics.Exporter, opts *pullOptions) (err error) {
 	ctx, span := trace.StartSpan(ctx, "federationin.pull")
 	defer func() {
 		if err != nil {
@@ -215,17 +226,17 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 	}()
 
 	logger := logging.FromContext(ctx)
-	logger.Infof("Processing query %q", q.QueryID)
+	logger.Infof("Processing query %q", opts.query.QueryID)
 
 	request := &pb.FederationFetchRequest{
-		RegionIdentifiers:             q.IncludeRegions,
-		ExcludeRegionIdentifiers:      q.ExcludeRegions,
-		LastFetchResponseKeyTimestamp: q.LastTimestamp.Unix(),
+		RegionIdentifiers:             opts.query.IncludeRegions,
+		ExcludeRegionIdentifiers:      opts.query.ExcludeRegions,
+		LastFetchResponseKeyTimestamp: opts.query.LastTimestamp.Unix(),
 	}
 
-	syncID, finalizeFn, err := deps.startFederationSync(ctx, q, batchStart)
+	syncID, finalizeFn, err := opts.deps.startFederationSync(ctx, opts.query, opts.batchStart)
 	if err != nil {
-		return fmt.Errorf("starting federation sync for query %s: %w", q.QueryID, err)
+		return fmt.Errorf("starting federation sync for query %s: %w", opts.query.QueryID, err)
 	}
 
 	var maxTimestamp time.Time
@@ -234,7 +245,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 		logger.Infof("Inserted %d keys", total)
 	}()
 
-	createdAt := publishmodel.TruncateWindow(batchStart, truncateWindow)
+	createdAt := publishmodel.TruncateWindow(opts.batchStart, opts.truncateWindow)
 	partial := true
 	nPartials := int64(0)
 	for partial {
@@ -243,9 +254,9 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 
 		// TODO(squee1945): react to the context timeout and complete a chunk of work so next invocation can pick up where left off.
 
-		response, err := deps.fetch(ctx, request)
+		response, err := opts.deps.fetch(ctx, request)
 		if err != nil {
-			return fmt.Errorf("fetching query %s: %w", q.QueryID, err)
+			return fmt.Errorf("fetching query %s: %w", opts.query.QueryID, err)
 		}
 
 		responseTimestamp := time.Unix(response.FetchResponseKeyTimestamp, 0)
@@ -281,7 +292,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 					})
 
 					if len(exposures) == fetchBatchSize {
-						if err := deps.insertExposures(ctx, exposures); err != nil {
+						if err := opts.deps.insertExposures(ctx, exposures); err != nil {
 							metrics.WriteInt("federation-pull-inserts", false, len(exposures))
 							return fmt.Errorf("inserting %d exposures: %w", len(exposures), err)
 						}
@@ -292,7 +303,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 			}
 		}
 		if len(exposures) > 0 {
-			if err := deps.insertExposures(ctx, exposures); err != nil {
+			if err := opts.deps.insertExposures(ctx, exposures); err != nil {
 				metrics.WriteInt("federation-pull-inserts", false, len(exposures))
 				return fmt.Errorf("inserting %d exposures: %w", len(exposures), err)
 			}
@@ -305,7 +316,7 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 
 	if err := finalizeFn(maxTimestamp, total); err != nil {
 		// TODO(squee1945): how do we clean up here? Just leave the records in and have the exporter eliminate them? Other?
-		return fmt.Errorf("finalizing federation sync for query %s: %w", q.QueryID, err)
+		return fmt.Errorf("finalizing federation sync for query %s: %w", opts.query.QueryID, err)
 	}
 
 	return nil
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 67b858d..dbe833f 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -118,6 +118,8 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		// A higher-level configuration error occurred, likely while trying to read
 		// from the database. This is retryable, although won't succeed if the error
 		// isn't transient.
+		// This message (and logging) will only contain the AppPkgName from the request
+		// and no other data from the request.
 		msg := fmt.Sprintf("no AuthorizedApp, dropping data: %v", err)
 		logger.Error(msg)
 		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: msg})
diff --git a/scripts/build b/scripts/build
index e81dcac..edb6351 100755
--- a/scripts/build
+++ b/scripts/build
@@ -17,7 +17,7 @@
 set -eEuo pipefail
 
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
-ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout,generate"
+ALL_SERVICES="cleanup-export,cleanup-exposure,debugger,export,exposure,federationin,federationout,generate"
 
 if [ -n "$(git status --porcelain)" ]; then
   echo "✋ Uncommitted local changes!" >&2
diff --git a/scripts/dev b/scripts/dev
index 98f2cb8..94aa79a 100755
--- a/scripts/dev
+++ b/scripts/dev
@@ -217,6 +217,10 @@ function protoc() {
       --go_out=plugins=grpc,paths=source_relative:. \
       ${ROOT}/internal/pb/*.proto ${ROOT}/internal/pb/export/*.proto
 
+  # protoc and goimports argue about formatting.
+  # in our main repo, we have formatting robot overloads, so put this back.
+  goimports -w internal/pb
+
   echo "Protos regenerated (OK)"
 }
 
diff --git a/terraform/main.tf b/terraform/main.tf
index a38e1c5..75f2b38 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -169,6 +169,14 @@ resource "google_app_engine_application" "app" {
   location_id = var.appengine_location
 }
 
+output "project_id" {
+  value = data.google_project.project.project_id
+}
+
+output "project_number" {
+  value = data.google_project.project.number
+}
+
 output "region" {
   value = var.region
 }
@@ -200,7 +208,3 @@ output "cloudrun_location" {
 output "storage_location" {
   value = var.storage_location
 }
-
-output "project" {
-  value = data.google_project.project.project_id
-}
diff --git a/terraform/service_debugger.tf b/terraform/service_debugger.tf
new file mode 100644
index 0000000..67976ea
--- /dev/null
+++ b/terraform/service_debugger.tf
@@ -0,0 +1,115 @@
+# Copyright 2020 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#
+# Create and deploy the service
+#
+
+resource "google_service_account" "debugger" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-debugger-sa"
+  display_name = "Exposure Notification debugger"
+}
+
+resource "google_service_account_iam_member" "cloudbuild-deploy-debugger" {
+  service_account_id = google_service_account.debugger.id
+  role               = "roles/iam.serviceAccountUser"
+  member             = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
+
+  depends_on = [
+    google_project_service.services["cloudbuild.googleapis.com"],
+  ]
+}
+
+resource "google_secret_manager_secret_iam_member" "debugger-db" {
+  provider = google-beta
+
+  for_each = toset([
+    "sslcert",
+    "sslkey",
+    "sslrootcert",
+    "password",
+  ])
+
+  secret_id = google_secret_manager_secret.db-secret[each.key].id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${google_service_account.debugger.email}"
+}
+
+resource "google_project_iam_member" "debugger-run-viewer" {
+  project  = google_cloud_run_service.generate.project
+  role     = "roles/run.viewer"
+  member   = "serviceAccount:${google_service_account.debugger.email}"
+}
+
+resource "google_cloud_run_service" "debugger" {
+  count = var.deploy_debugger ? 1 : 0
+
+  name     = "debugger"
+  location = var.cloudrun_location
+
+  template {
+    spec {
+      service_account_name = google_service_account.debugger.email
+
+      containers {
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/debugger:initial"
+
+        resources {
+          limits = {
+            cpu    = "2"
+            memory = "1G"
+          }
+        }
+
+        dynamic "env" {
+          for_each = local.common_cloudrun_env_vars
+          content {
+            name  = env.value["name"]
+            value = env.value["value"]
+          }
+        }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "debugger", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
+      }
+
+      container_concurrency = 10
+    }
+
+    metadata {
+      annotations = {
+        "autoscaling.knative.dev/maxScale" : "10",
+        "run.googleapis.com/vpc-access-connector" : google_vpc_access_connector.connector.id
+      }
+    }
+  }
+
+  depends_on = [
+    google_project_service.services["run.googleapis.com"],
+    google_secret_manager_secret_iam_member.debugger-db,
+    null_resource.build,
+  ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
+}
diff --git a/terraform/vars.tf b/terraform/vars.tf
index 1c1ac55..32d99c3 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -100,11 +100,11 @@ variable "generate_cron_schedule" {
   description = "Schedule to execute the generation service."
 }
 
-variable "registry_cleanup_cron_schedule" {
-  type    = string
-  default = "0 0 1 1 0"
+variable "deploy_debugger" {
+  type = bool
+  default = false
 
-  description = "Schedule to execute cleanup of old Container Registry images."
+  description = "Deploy the service debugger. Use only in testing."
 }
 
 variable "service_environment" {
diff --git a/tools/admin-console/templates/healthauthority.html b/tools/admin-console/templates/healthauthority.html
index c4a7217..53f62a2 100644
--- a/tools/admin-console/templates/healthauthority.html
+++ b/tools/admin-console/templates/healthauthority.html
@@ -32,9 +32,10 @@ health authority.</p>
     <label class="control-label col-sm-3" for="Audience">Audience:</label>
     <div class="col-sm-6">
       <input type="text" id="Audience" name="Audience" size="50" value="{{.ha.Audience}}">
-      <small id="AudienceHelpBlock" class="form-text text-muted">The audience that will
-        be present, specific to this server if the verifying authority is working
-        with multiple backends. This is the 'aud' field of the certificate JWT.
+      <small id="AudienceHelpBlock" class="form-text text-muted">
+        The valud for audience is defined by the operator of this server. It should be
+        communicated to the health authority for inclusion in the verification
+        certificates. This is the 'aud' field of the certificate JWT.
         <strong>Once set, this field should not be edited.</strong></small>
     </div>
   </div>
diff --git a/tools/export-generate/main.go b/tools/export-generate/main.go
index 50ca5ab..444c334 100644
--- a/tools/export-generate/main.go
+++ b/tools/export-generate/main.go
@@ -160,30 +160,55 @@ func main() {
 		currentBatch = append(currentBatch, &exposureKeys[i])
 		if len(currentBatch) == *batchSize {
 			b++
-			writeFile(eb, currentBatch, b, numBatches, actualNumKeys, privateKey)
+			w := exportFileWriter{
+				exportBatch: eb,
+				exposures:   currentBatch,
+				curBatch:    b,
+				numBatches:  numBatches,
+				totalKeys:   actualNumKeys,
+				privateKey:  privateKey,
+			}
+			w.writeFile()
 			currentBatch = []*publishmodel.Exposure{}
 		}
 	}
 	if len(currentBatch) > 0 {
 		b++
-		writeFile(eb, currentBatch, b, numBatches, actualNumKeys, privateKey)
+		w := exportFileWriter{
+			exportBatch: eb,
+			exposures:   currentBatch,
+			curBatch:    b,
+			numBatches:  numBatches,
+			totalKeys:   actualNumKeys,
+			privateKey:  privateKey,
+		}
+		w.writeFile()
 	}
 }
 
-func writeFile(eb *model.ExportBatch, currentBatch []*publishmodel.Exposure, b, numBatches, numRecords int, privateKey *ecdsa.PrivateKey) {
+type exportFileWriter struct {
+	exportBatch *model.ExportBatch
+	exposures   []*publishmodel.Exposure
+	curBatch    int
+	numBatches  int
+	totalKeys   int
+	privateKey  *ecdsa.PrivateKey
+}
+
+func (e *exportFileWriter) writeFile() {
 	signatureInfo := &model.SignatureInfo{
 		SigningKeyID:      *keyID,
 		SigningKeyVersion: *keyVersion,
 	}
 	signer := &export.Signer{
 		SignatureInfo: signatureInfo,
-		Signer:        privateKey,
+		Signer:        e.privateKey,
 	}
-	data, err := export.MarshalExportFile(eb, currentBatch, b, numBatches, []*export.Signer{signer})
+	data, err := export.MarshalExportFile(e.exportBatch, e.exposures, e.curBatch, e.numBatches, []*export.Signer{signer})
 	if err != nil {
 		log.Fatalf("error marshaling export file: %v", err)
 	}
-	fileName := fmt.Sprintf(eb.FilenameRoot+"%d-records-%d-of-%d"+filenameSuffix, numRecords, b, numBatches)
+	fileName := fmt.Sprintf(e.exportBatch.FilenameRoot+"%d-records-%d-of-%d"+filenameSuffix, e.totalKeys, e.curBatch, e.numBatches)
 	log.Printf("Creating %v", fileName)
 	err = ioutil.WriteFile(fileName, data, 0666)
 	if err != nil {


Test patch:
diff --git a/internal/export/server_test.go b/internal/export/server_test.go
index 63c74ec..eb66493 100644
--- a/internal/export/server_test.go
+++ b/internal/export/server_test.go
@@ -42,15 +42,29 @@ func TestNewServer(t *testing.T) {
 			env:  serverenv.New(ctx),
 			err:  fmt.Errorf("export.NewBatchServer requires Blobstore present in the ServerEnv"),
 		},
+		{
+			name: "nil Database",
+			env: serverenv.New(ctx,
+				serverenv.WithBlobStorage(emptyStorage),
+			),
+			err: fmt.Errorf("export.NewBatchServer requires Database present in the ServerEnv"),
+		},
 		{
 			name: "nil KeyManager",
-			env:  serverenv.New(ctx, serverenv.WithBlobStorage(emptyStorage)),
-			err:  fmt.Errorf("export.NewBatchServer requires KeyManager present in the ServerEnv"),
+			env: serverenv.New(ctx,
+				serverenv.WithBlobStorage(emptyStorage),
+				serverenv.WithDatabase(emptyDB),
+			),
+			err: fmt.Errorf("export.NewBatchServer requires KeyManager present in the ServerEnv"),
 		},
 		{
 			name: "Fully Specified",
-			env:  serverenv.New(ctx, serverenv.WithBlobStorage(emptyStorage), serverenv.WithKeyManager(emptyKMS), serverenv.WithDatabase(emptyDB)),
-			err:  nil,
+			env: serverenv.New(ctx,
+				serverenv.WithBlobStorage(emptyStorage),
+				serverenv.WithDatabase(emptyDB),
+				serverenv.WithKeyManager(emptyKMS),
+			),
+			err: nil,
 		},
 	}
 
diff --git a/internal/federationin/federationin_test.go b/internal/federationin/federationin_test.go
index 5cd7146..ece2fc3 100644
--- a/internal/federationin/federationin_test.go
+++ b/internal/federationin/federationin_test.go
@@ -276,13 +276,19 @@ func TestFederationPull(t *testing.T) {
 				fetchBatchSize = tc.batchSize
 				defer func() { fetchBatchSize = oldBatchSize }()
 			}
-			deps := pullDependencies{
-				fetch:               remote.fetch,
-				insertExposures:     idb.insertExposures,
-				startFederationSync: sdb.startFederationSync,
+
+			opts := pullOptions{
+				deps: pullDependencies{
+					fetch:               remote.fetch,
+					insertExposures:     idb.insertExposures,
+					startFederationSync: sdb.startFederationSync,
+				},
+				query:          query,
+				batchStart:     batchStart,
+				truncateWindow: time.Hour,
 			}
 
-			err := pull(ctx, metrics.NewLogsBasedFromContext(ctx), deps, query, batchStart, time.Hour)
+			err := pull(ctx, metrics.NewLogsBasedFromContext(ctx), &opts)
 			if err != nil {
 				t.Fatalf("pull returned err=%v, want err=nil", err)
 			}
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index d54997f..f4d5738 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -112,8 +112,7 @@ func testServer(tb testing.TB) (*serverenv.ServerEnv, *http.Client) {
 	if err != nil {
 		tb.Fatal(err)
 	}
-	mux.HandleFunc("/export/create-batches", exportServer.CreateBatchesHandler)
-	mux.HandleFunc("/export/do-work", exportServer.WorkerHandler)
+	mux.Handle("/export/", http.StripPrefix("/export", exportServer.Routes(ctx)))
 
 	// Federation
 	federationInConfig := &federationin.Config{
diff --git a/internal/integration/publish_test.go b/internal/integration/publish_test.go
index 147dcd3..dc5f103 100644
--- a/internal/integration/publish_test.go
+++ b/internal/integration/publish_test.go
@@ -134,11 +134,11 @@ func TestPublish(t *testing.T) {
 	defer resp.Body.Close()
 
 	// TODO: verify export has the correct file
-	b, err := env.Blobstore().GetObject(ctx, "my-bucket", "index.txt")
-	if err != nil {
-		t.Fatal(err)
-	}
-	_ = b
+	// b, err := env.Blobstore().GetObject(ctx, "my-bucket", "index.txt")
+	// if err != nil {
+	// 	t.Fatal(err)
+	// }
+	// _ = b
 
 	// TODO: verify signature
 }