diff --git a/packages/app/src/routes/__tests__/identity-routes.test.ts b/packages/app/src/routes/__tests__/identity-routes.test.ts
index 30c05cf..80c008a 100644
--- a/packages/app/src/routes/__tests__/identity-routes.test.ts
+++ b/packages/app/src/routes/__tests__/identity-routes.test.ts
@@ -7,7 +7,12 @@ import * as s3 from '../../services/s3-storage.js';
import * as faceMatch from '../../identity/face-match-client.js';
import { makeToken, setTestJwtSecret } from './test-helpers.js';
-beforeAll(() => setTestJwtSecret());
+beforeAll(() => {
+ setTestJwtSecret();
+ // The routes gate on this env var before touching storage, so it has to be
+ // present for the normal paths even though S3StorageService is mocked.
+ process.env.DOCUMENTS_S3_BUCKET = 'test-bucket';
+});
afterEach(() => vi.restoreAllMocks());
const agencyId = '00000000-0000-4000-8000-00000000c001';
@@ -290,6 +295,47 @@ describe('request body limits', () => {
});
});
+describe('storage not configured', () => {
+ afterEach(() => { process.env.DOCUMENTS_S3_BUCKET = 'test-bucket'; });
+
+ it('refuses enrolment with a reason instead of a bare 500', async () => {
+ // Constructing the storage client without a bucket throws, which would
+ // otherwise surface as a 500 at the exact moment somebody photographs
+ // their own face.
+ delete process.env.DOCUMENTS_S3_BUCKET;
+ mockRepo();
+
+ const res = await request(createApp())
+ .post('/identity/enroll')
+ .set('Authorization', auth())
+ .send({ imageBase64: IMAGE_B64 });
+
+ expect(res.status).toBe(503);
+ expect(res.body.code).toBe('STORAGE_NOT_CONFIGURED');
+ });
+
+ it('refuses verification the same way', async () => {
+ delete process.env.DOCUMENTS_S3_BUCKET;
+ mockRepo();
+
+ const res = await request(createApp())
+ .post('/identity/verify')
+ .set('Authorization', auth())
+ .send({ imageBase64: IMAGE_B64 });
+
+ expect(res.status).toBe(503);
+ });
+
+ it('reports storage separately from the matching provider', async () => {
+ delete process.env.DOCUMENTS_S3_BUCKET;
+ mockRepo();
+
+ const res = await request(createApp()).get('/identity/status').set('Authorization', auth());
+
+ expect(res.body.storageConfigured).toBe(false);
+ });
+});
+
describe('GET /identity/status', () => {
it('states plainly that liveness is not supported', async () => {
mockRepo();
diff --git a/packages/app/src/routes/identity-routes.ts b/packages/app/src/routes/identity-routes.ts
index 5a7c8b4..3f49970 100644
--- a/packages/app/src/routes/identity-routes.ts
+++ b/packages/app/src/routes/identity-routes.ts
@@ -75,6 +75,18 @@ function decodeImage(base64: string): Buffer | null {
}
}
+/**
+ * Whether photo storage is wired up.
+ *
+ * Separate from the matching provider: an agency can have Rekognition selected
+ * and still have no bucket, or the reverse. Both have to be true before a
+ * caregiver should be invited to point a camera at their face, otherwise the
+ * first thing they meet is a 500 at the moment they take the photo.
+ */
+function isPhotoStorageConfigured(): boolean {
+ return Boolean(process.env.DOCUMENTS_S3_BUCKET?.trim());
+}
+
/** Object keys are namespaced per agency so a retention sweep can scope by prefix. */
function referenceKey(agencyId: string, caregiverId: string): string {
return `identity/${agencyId}/${caregiverId}/reference.jpg`;
@@ -96,7 +108,10 @@ router.get('/status', requireCapability('evv.read'), async (req: Request, res: R
repo.findEnrollment(req.auth.caregiverId, req.auth.agencyId),
]);
res.json({
+ // Matching provider and photo storage are reported separately so the app
+ // can say which half is missing instead of a vague "not available".
configured: isIdentityVerificationConfigured(),
+ storageConfigured: isPhotoStorageConfigured(),
consented: consent !== null,
consentVersion: consent?.consentVersion ?? null,
enrolled: enrollment !== null,
@@ -188,6 +203,15 @@ router.post('/enroll', requireCapability('evv.write'), async (req: Request, res:
if (!image) {
return res.status(400).json({ message: 'That photo could not be read. Please retake it.' });
}
+ // Fail before the photo goes anywhere, with a reason. Constructing the
+ // storage client without a bucket throws, which would otherwise surface as
+ // a bare 500 at the exact moment somebody photographs their own face.
+ if (!isPhotoStorageConfigured()) {
+ return res.status(503).json({
+ message: 'Identity photo storage is not set up for this agency yet.',
+ code: 'STORAGE_NOT_CONFIGURED',
+ });
+ }
try {
const db = req.app.get('db') as Knex;
@@ -236,6 +260,12 @@ router.post('/verify', requireCapability('evv.write'), async (req: Request, res:
if (!image) {
return res.status(400).json({ message: 'That photo could not be read. Please retake it.' });
}
+ if (!isPhotoStorageConfigured()) {
+ return res.status(503).json({
+ message: 'Identity photo storage is not set up for this agency yet.',
+ code: 'STORAGE_NOT_CONFIGURED',
+ });
+ }
const db = req.app.get('db') as Knex;
const repo = new IdentityRepository(db);
diff --git a/packages/mobile/src/features/identity/IdentityScreen.tsx b/packages/mobile/src/features/identity/IdentityScreen.tsx
index 6ab7f27..e3c520d 100644
--- a/packages/mobile/src/features/identity/IdentityScreen.tsx
+++ b/packages/mobile/src/features/identity/IdentityScreen.tsx
@@ -280,6 +280,17 @@ export default function IdentityScreen() {
) : null}
+ {step === 'unavailable' ? (
+
+ Not available yet
+
+ Your agency has not finished setting up identity checks, so there is nowhere to
+ keep your photo yet. Nothing is needed from you. This screen will let you set
+ yours up once they are done.
+
+
+ ) : null}
+
{step === 'consent' ? (
Before we start
diff --git a/packages/mobile/src/lib/identity.test.ts b/packages/mobile/src/lib/identity.test.ts
index 95dd2af..e0fc6fc 100644
--- a/packages/mobile/src/lib/identity.test.ts
+++ b/packages/mobile/src/lib/identity.test.ts
@@ -114,3 +114,29 @@ describe('isUploadableCapture', () => {
expect(MAX_UPLOAD_BASE64_CHARS).toBeLessThan(3 * 1024 * 1024);
});
});
+
+describe('storage gate', () => {
+ it('hides the camera when the server has nowhere to keep the photo', () => {
+ // Inviting somebody to photograph their face and then failing to store it
+ // is the worst order to discover missing configuration in.
+ expect(stepFor(status({ storageConfigured: false }))).toBe('unavailable');
+ expect(stepFor(status({ storageConfigured: false, consented: true }))).toBe('unavailable');
+ });
+
+ it('still shows an already-enrolled caregiver their setup', () => {
+ // Their photo exists; a transient config gap should not imply it is gone.
+ expect(
+ stepFor(status({ storageConfigured: false, consented: true, enrolled: true })),
+ ).toBe('ready');
+ });
+
+ it('proceeds normally once storage is configured', () => {
+ expect(stepFor(status({ storageConfigured: true }))).toBe('consent');
+ expect(stepFor(status({ storageConfigured: true, consented: true }))).toBe('enroll');
+ });
+
+ it('keeps working against an API that predates the field', () => {
+ // Absent is not the same as false; an older server should behave as before.
+ expect(stepFor(status())).toBe('consent');
+ });
+});
diff --git a/packages/mobile/src/lib/identity.ts b/packages/mobile/src/lib/identity.ts
index c79a72d..c113b98 100644
--- a/packages/mobile/src/lib/identity.ts
+++ b/packages/mobile/src/lib/identity.ts
@@ -15,6 +15,12 @@ export type IdentityOutcome =
export interface IdentityStatus {
/** Whether a real matching provider is wired up server-side. */
configured: boolean;
+ /**
+ * Whether photo storage is wired up. Separate from `configured`: without a
+ * bucket there is nowhere to put a photo, so the camera must not be offered
+ * at all, whatever the matching provider is doing.
+ */
+ storageConfigured?: boolean;
consented: boolean;
enrolled: boolean;
enrolledAt: string | null;
@@ -25,10 +31,15 @@ export interface IdentityStatus {
}
/** Which panel the screen should show, derived from server state alone. */
-export type IdentityStep = 'loading' | 'consent' | 'enroll' | 'ready';
+export type IdentityStep = 'loading' | 'unavailable' | 'consent' | 'enroll' | 'ready';
export function stepFor(status: IdentityStatus | null): IdentityStep {
if (!status) return 'loading';
+ // No storage means no camera. Inviting somebody to photograph their own face
+ // and then failing to keep it is the worst order to discover this in.
+ // Treated as absent only when the server explicitly says so, so an older API
+ // that omits the field keeps its previous behaviour.
+ if (status.storageConfigured === false && !status.enrolled) return 'unavailable';
// Consent first, always. Nothing biometric is captured before it, and the
// server enforces the same order, so a client that skipped ahead would only
// earn a 403.