gestalt-mobile 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ <!--
2
+ Copyright (C) 2026 Dyne.org foundation
3
+ Designed by Denis Roio <jaromil@dyne.org>
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+ -->
6
+
7
+ <svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 512 512">
8
+ <rect width="512" height="512" fill="#141414" />
9
+ <image href="../branding/p_glogo_white.svg" x="64" y="64" width="384" height="384" />
10
+ </svg>
@@ -10,9 +10,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later
10
10
  <meta charset="UTF-8" />
11
11
  <meta name="viewport" content="width=device-width, initial-scale=1" />
12
12
  <meta name="color-scheme" content="light dark" />
13
- <title>Codex Relay</title>
14
- <script type="module" crossorigin src="/assets/index-tMyOolAm.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-C5qFcFUg.css">
13
+ <meta name="theme-color" content="#141414" />
14
+ <meta name="application-name" content="Gestalt Mobile" />
15
+ <link rel="manifest" href="/manifest.webmanifest" />
16
+ <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
+ <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
+ <title>Gestalt Mobile</title>
19
+ <script type="module" crossorigin src="/assets/index-Dnm_kS5p.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-2L2KZzwK.css">
16
21
  </head>
17
22
  <body>
18
23
  <div id="app"></div>
@@ -0,0 +1,32 @@
1
+ {
2
+ "id": "/",
3
+ "name": "Gestalt Mobile",
4
+ "short_name": "Gestalt",
5
+ "description": "A mobile interface for durable Codex development sessions.",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#141414",
10
+ "theme_color": "#141414",
11
+ "categories": ["productivity", "utilities"],
12
+ "icons": [
13
+ {
14
+ "src": "/icons/gestalt-mobile-192.png",
15
+ "sizes": "192x192",
16
+ "type": "image/png",
17
+ "purpose": "any"
18
+ },
19
+ {
20
+ "src": "/icons/gestalt-mobile-512.png",
21
+ "sizes": "512x512",
22
+ "type": "image/png",
23
+ "purpose": "any"
24
+ },
25
+ {
26
+ "src": "/icons/gestalt-mobile-maskable-512.png",
27
+ "sizes": "512x512",
28
+ "type": "image/png",
29
+ "purpose": "maskable"
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+
7
+ // Installability only: authenticated relay data must always remain network-fresh.
8
+ self.addEventListener('install', () => self.skipWaiting());
9
+ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
@@ -3,6 +3,12 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- /** Expected proof failure; adapters must translate library-specific failures to this type. */
6
+ /** Expected proof failure; adapters translate library details to a safe diagnostic category. */
7
7
  export class PasskeyVerificationError extends Error {
8
+ reason;
9
+ constructor(reason = 'VERIFIER_REJECTED') {
10
+ super('PASSKEY_VERIFICATION_FAILED');
11
+ this.reason = reason;
12
+ this.name = 'PasskeyVerificationError';
13
+ }
8
14
  }
@@ -50,7 +50,7 @@ export function registerLoginVerification(app, deps) {
50
50
  },
51
51
  });
52
52
  if (!verified.userVerified || verified.credentialId !== device.credentialId)
53
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
53
+ throw new PasskeyVerificationError('USER_VERIFICATION_REQUIRED');
54
54
  const session = deps.identifiers.sessionId();
55
55
  if (Buffer.from(session, 'base64url').length !== 32)
56
56
  throw new Error('AUTHORIZATION_RANDOMNESS_INVALID');
@@ -35,11 +35,20 @@ export function registerRegistrationVerification(app, deps) {
35
35
  app.post('/api/auth/register/verify', { bodyLimit: 128 * 1024 }, async (request, reply) => {
36
36
  const parsed = requestSchema.safeParse(request.body);
37
37
  const token = request.cookies.gestalt_mobile_registration;
38
- if (!parsed.success || typeof token !== 'string')
38
+ if (!parsed.success) {
39
+ deps.reportVerificationFailure?.('INVALID_RESPONSE_SHAPE');
39
40
  return reply
40
41
  .code(400)
41
42
  .type('application/problem+json')
42
43
  .send(problem('INVALID_REGISTRATION_REQUEST', 400, 'The registration request is invalid.'));
44
+ }
45
+ if (typeof token !== 'string') {
46
+ deps.reportVerificationFailure?.('MISSING_CEREMONY_COOKIE');
47
+ return reply
48
+ .code(400)
49
+ .type('application/problem+json')
50
+ .send(problem('REGISTRATION_COOKIE_MISSING', 400, 'The registration cookie is missing.'));
51
+ }
43
52
  let nickname;
44
53
  try {
45
54
  nickname = deviceNickname(parsed.data.nickname);
@@ -54,11 +63,13 @@ export function registerRegistrationVerification(app, deps) {
54
63
  }
55
64
  const now = deps.clock.now();
56
65
  const ceremony = deps.repository.readCeremony(passkeyCeremonyId(token), now.toISOString());
57
- if (!ceremony || ceremony.purpose !== 'registration')
66
+ if (!ceremony || ceremony.purpose !== 'registration') {
67
+ deps.reportVerificationFailure?.('CEREMONY_NOT_AVAILABLE');
58
68
  return reply
59
69
  .code(400)
60
70
  .type('application/problem+json')
61
71
  .send(problem('REGISTRATION_NOT_AVAILABLE', 400, 'Registration could not be completed.'));
72
+ }
62
73
  try {
63
74
  const verified = await deps.webauthn.verifyRegistration({
64
75
  response: parsed.data.response,
@@ -67,7 +78,7 @@ export function registerRegistrationVerification(app, deps) {
67
78
  rpId: ceremony.rpId,
68
79
  });
69
80
  if (!verified.userVerified)
70
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
81
+ throw new PasskeyVerificationError('USER_VERIFICATION_REQUIRED');
71
82
  const device = {
72
83
  id: deps.identifiers.deviceId(),
73
84
  credentialId: verified.credentialId,
@@ -101,11 +112,15 @@ export function registerRegistrationVerification(app, deps) {
101
112
  return reply.code(201).send(successSchema.parse({ status: 'authenticated' }));
102
113
  }
103
114
  catch (error) {
104
- if (error instanceof PasskeyVerificationError || error instanceof AuthorizationDomainError)
115
+ if (error instanceof PasskeyVerificationError || error instanceof AuthorizationDomainError) {
116
+ deps.reportVerificationFailure?.(error instanceof PasskeyVerificationError
117
+ ? `WEBAUTHN_${error.reason}`
118
+ : 'AUTHORIZATION_REJECTED');
105
119
  return reply
106
120
  .code(400)
107
121
  .type('application/problem+json')
108
122
  .send(problem('REGISTRATION_VERIFICATION_FAILED', 400, 'Registration could not be completed.'));
123
+ }
109
124
  throw error;
110
125
  }
111
126
  });
@@ -24,7 +24,10 @@ export function registerAuthRoutes(app, deps) {
24
24
  ceremonyAttempts: deps.auth.ceremonyAttempts ?? new ExpiringCeremonyAttemptGate(),
25
25
  };
26
26
  registerRegistrationOptions(app, auth);
27
- registerRegistrationVerification(app, deps.auth);
27
+ registerRegistrationVerification(app, {
28
+ ...deps.auth,
29
+ reportVerificationFailure: (reason) => deps.logger.warn(`Passkey registration verification failed: ${reason}`),
30
+ });
28
31
  registerLoginOptions(app, auth);
29
32
  registerLoginVerification(app, deps.auth);
30
33
  registerAuthStatus(app, deps.auth);
@@ -7,6 +7,32 @@ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthe
7
7
  import { PasskeyVerificationError } from '../../features/auth/application/ports.js';
8
8
  import { webAuthnCredentialId } from '../../features/auth/domain/identifiers.js';
9
9
  const transports = new Set(['ble', 'hybrid', 'internal', 'nfc', 'usb']);
10
+ function verificationFailure(error) {
11
+ const message = error instanceof Error ? error.message.toLowerCase() : '';
12
+ if (message.includes('challenge'))
13
+ return 'CHALLENGE_MISMATCH';
14
+ if (message.includes('origin'))
15
+ return 'ORIGIN_MISMATCH';
16
+ if (message.includes('rp id') || message.includes('rpid'))
17
+ return 'RP_ID_MISMATCH';
18
+ if (message.includes('user presence'))
19
+ return 'USER_PRESENCE_REQUIRED';
20
+ if (message.includes('user verification'))
21
+ return 'USER_VERIFICATION_REQUIRED';
22
+ if (message.includes('credential id') || message.includes('base64'))
23
+ return 'CREDENTIAL_ENCODING_INVALID';
24
+ if (message.includes('alg') || message.includes('cose'))
25
+ return 'ALGORITHM_UNSUPPORTED';
26
+ if (message.includes('public key') ||
27
+ message.includes('aaguid') ||
28
+ message.includes('authenticator data'))
29
+ return 'CREDENTIAL_MATERIAL_MISSING';
30
+ if (message.includes('attestation'))
31
+ return 'ATTESTATION_UNSUPPORTED';
32
+ if (message.includes('signature'))
33
+ return 'SIGNATURE_INVALID';
34
+ return 'VERIFIER_REJECTED';
35
+ }
10
36
  /** Translates SimpleWebAuthn DTOs at the platform boundary. */
11
37
  export class SimpleWebAuthnAdapter {
12
38
  async registrationOptions(input) {
@@ -47,11 +73,11 @@ export class SimpleWebAuthnAdapter {
47
73
  supportedAlgorithmIDs: [-7, -257],
48
74
  });
49
75
  }
50
- catch {
51
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
76
+ catch (error) {
77
+ throw new PasskeyVerificationError(verificationFailure(error));
52
78
  }
53
79
  if (!result.verified || !result.registrationInfo?.userVerified)
54
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
80
+ throw new PasskeyVerificationError('VERIFIER_REJECTED');
55
81
  const credential = result.registrationInfo.credential;
56
82
  return {
57
83
  credentialId: webAuthnCredentialId(credential.id),
@@ -81,11 +107,11 @@ export class SimpleWebAuthnAdapter {
81
107
  },
82
108
  });
83
109
  }
84
- catch {
85
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
110
+ catch (error) {
111
+ throw new PasskeyVerificationError(verificationFailure(error));
86
112
  }
87
113
  if (!result.verified || !result.authenticationInfo?.userVerified)
88
- throw new PasskeyVerificationError('PASSKEY_VERIFICATION_FAILED');
114
+ throw new PasskeyVerificationError('VERIFIER_REJECTED');
89
115
  return {
90
116
  credentialId: webAuthnCredentialId(value.response.id),
91
117
  counter: result.authenticationInfo.newCounter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",