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.
- package/dist/client/assets/{index-C5qFcFUg.css → index-2L2KZzwK.css} +1 -1
- package/dist/client/assets/index-Dnm_kS5p.js +24 -0
- package/dist/client/icons/gestalt-mobile-180.png +0 -0
- package/dist/client/icons/gestalt-mobile-192.png +0 -0
- package/dist/client/icons/gestalt-mobile-512.png +0 -0
- package/dist/client/icons/gestalt-mobile-maskable-512.png +0 -0
- package/dist/client/icons/gestalt-mobile-maskable.svg +10 -0
- package/dist/client/icons/gestalt-mobile.svg +10 -0
- package/dist/client/index.html +8 -3
- package/dist/client/manifest.webmanifest +32 -0
- package/dist/client/service-worker.js +9 -0
- package/dist/server/server/features/auth/application/ports.js +7 -1
- package/dist/server/server/features/auth/login/verify/endpoint.js +1 -1
- package/dist/server/server/features/auth/register/verify/endpoint.js +19 -4
- package/dist/server/server/features/auth/register-routes.js +4 -1
- package/dist/server/server/platform/auth/simple-webauthn-adapter.js +32 -6
- package/package.json +1 -1
- package/dist/client/assets/index-tMyOolAm.js +0 -24
|
@@ -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>
|
package/dist/client/index.html
CHANGED
|
@@ -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
|
-
<
|
|
14
|
-
<
|
|
15
|
-
<link rel="
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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,
|
|
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(
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new PasskeyVerificationError(verificationFailure(error));
|
|
52
78
|
}
|
|
53
79
|
if (!result.verified || !result.registrationInfo?.userVerified)
|
|
54
|
-
throw new PasskeyVerificationError('
|
|
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(
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw new PasskeyVerificationError(verificationFailure(error));
|
|
86
112
|
}
|
|
87
113
|
if (!result.verified || !result.authenticationInfo?.userVerified)
|
|
88
|
-
throw new PasskeyVerificationError('
|
|
114
|
+
throw new PasskeyVerificationError('VERIFIER_REJECTED');
|
|
89
115
|
return {
|
|
90
116
|
credentialId: webAuthnCredentialId(value.response.id),
|
|
91
117
|
counter: result.authenticationInfo.newCounter,
|